mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
refactor(portal): refactor billing event handler (#10064)
Why: * There were intermittent issues with accounts updates from Stripe events. Specifically, when an account would update it's subscription from Starter to Team. The reason was due to the fact that Stripe does not guarantee order of delivery for it's webhook events. At times we were seeing and responding to an event that was a few seconds old after processing a newer event. This would have the effect of quickly transitioning an account from Team back to Starter. This commit refactors our event handler and adds a `processed_stripe_events` DB table to make sure we don't process duplicate events as well as prevent processing an event that was created prior to the last event we've processed for a given account. * Along with refactoring the billing event handling, the Stripe mock module has also been refactored to better reflect real Stripe objects. Related: #8668
This commit is contained in:
@@ -144,7 +144,7 @@ defmodule Domain.Billing do
|
||||
defp get_customer_email(%{metadata: %{stripe: %{billing_email: email}}}), do: email
|
||||
defp get_customer_email(_account), do: nil
|
||||
|
||||
def update_customer(%Accounts.Account{} = account) do
|
||||
def update_stripe_customer(%Accounts.Account{} = account) do
|
||||
secret_key = fetch_config!(:secret_key)
|
||||
customer_id = account.metadata.stripe.customer_id
|
||||
|
||||
@@ -316,7 +316,7 @@ defmodule Domain.Billing do
|
||||
:ok
|
||||
|
||||
true ->
|
||||
{:ok, _customer} = update_customer(account)
|
||||
{:ok, _customer} = update_stripe_customer(account)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,218 +1,315 @@
|
||||
defmodule Domain.Billing.EventHandler do
|
||||
@moduledoc """
|
||||
Handles Stripe webhook events for billing and subscription management.
|
||||
"""
|
||||
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts
|
||||
alias Domain.Billing
|
||||
alias Domain.Billing.Stripe.ProcessedEvents
|
||||
require Logger
|
||||
|
||||
# customer is created
|
||||
def handle_event(%{
|
||||
"object" => "event",
|
||||
"data" => %{
|
||||
"object" => customer
|
||||
},
|
||||
"type" => "customer.created"
|
||||
}) do
|
||||
create_account_from_stripe_customer(customer)
|
||||
@subscription_events ["created", "resumed"]
|
||||
|
||||
def handle_event(%{"object" => "event"} = event) do
|
||||
process_event_with_lock(event)
|
||||
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_metadata["account_name"] || customer_name,
|
||||
legal_name: customer_name,
|
||||
metadata: %{stripe: %{billing_email: customer_email}}
|
||||
}
|
||||
|> put_if_not_nil(:slug, customer_metadata["account_slug"])
|
||||
defp process_event_with_lock(event) do
|
||||
customer_id = extract_customer_id(event)
|
||||
hashed_id = :erlang.phash2(customer_id)
|
||||
|
||||
Repo.transact(fn ->
|
||||
Repo.query!("SELECT pg_advisory_xact_lock($1)", [hashed_id])
|
||||
process_event(event, customer_id)
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_event(event, customer_id) do
|
||||
with :ok <- check_event_processing_eligibility(event, customer_id),
|
||||
:ok <- process_event_by_type(event),
|
||||
:ok <- record_processed_event(event, customer_id) do
|
||||
{:ok, event}
|
||||
else
|
||||
{:skip, reason} ->
|
||||
Logger.info("Skipping stripe event", reason: inspect(reason))
|
||||
{:ok, event}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to process stripe event",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
|
||||
error ->
|
||||
Logger.error("Unknown failure processing stripe event",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(error)
|
||||
)
|
||||
|
||||
{:error, :unknown_failure}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_event_processing_eligibility(event, customer_id) do
|
||||
%{
|
||||
"id" => event_id,
|
||||
"created" => event_created,
|
||||
"type" => event_type
|
||||
} = event
|
||||
|
||||
if ProcessedEvents.event_processed?(event_id) do
|
||||
{:skip, :already_processed}
|
||||
else
|
||||
check_chronological_order(customer_id, event_created, event_type, event_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp check_chronological_order(nil, _event_created, _event_type, _event_id) do
|
||||
{:skip, :no_customer_id}
|
||||
end
|
||||
|
||||
defp check_chronological_order(customer_id, event_created, event_type, event_id) do
|
||||
case ProcessedEvents.get_latest_for_stripe_customer(customer_id, event_type) do
|
||||
nil ->
|
||||
:ok
|
||||
|
||||
latest_event ->
|
||||
event_created_at = DateTime.from_unix!(event_created)
|
||||
|
||||
case DateTime.compare(event_created_at, latest_event.event_created_at) do
|
||||
:gt ->
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
Logger.info("Skipping older event",
|
||||
event_id: event_id,
|
||||
event_created: event_created,
|
||||
latest_processed_created: latest_event.event_created_at,
|
||||
customer_id: customer_id
|
||||
)
|
||||
|
||||
{:skip, :old_event}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_customer_id(%{"data" => %{"object" => object}} = event) when is_map(event) do
|
||||
case Map.get(object, "object") do
|
||||
"customer" -> Map.get(object, "id")
|
||||
_ -> Map.get(object, "customer")
|
||||
end
|
||||
end
|
||||
|
||||
defp process_event_by_type(event) do
|
||||
event_type = Map.get(event, "type")
|
||||
event_data = get_in(event, ["data", "object"])
|
||||
|
||||
case event_type do
|
||||
"customer.created" ->
|
||||
handle_customer_created(event_data)
|
||||
|
||||
"customer.updated" ->
|
||||
handle_customer_updated(event_data)
|
||||
|
||||
"customer.subscription.deleted" ->
|
||||
handle_subscription_deleted(event_data)
|
||||
|
||||
# This event only sent after a Stripe trial has ended
|
||||
"customer.subscription.paused" ->
|
||||
handle_subscription_paused(event_data)
|
||||
|
||||
"customer.subscription.updated" ->
|
||||
handle_subscription_updated(event_data)
|
||||
|
||||
"customer.subscription." <> sub_event when sub_event in @subscription_events ->
|
||||
handle_subscription_active(event_data)
|
||||
|
||||
_ ->
|
||||
handle_unknown_event(event_type, event_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp record_processed_event(event, customer_id) do
|
||||
event_id = Map.get(event, "id")
|
||||
|
||||
attrs = %{
|
||||
stripe_event_id: event_id,
|
||||
event_type: Map.get(event, "type"),
|
||||
processed_at: DateTime.utc_now(),
|
||||
stripe_customer_id: customer_id,
|
||||
event_created_at: DateTime.from_unix!(Map.get(event, "created")),
|
||||
livemode: Map.get(event, "livemode", false),
|
||||
request_id: Map.get(event, "request")
|
||||
}
|
||||
|
||||
case ProcessedEvents.create_processed_event(attrs) do
|
||||
{:ok, _processed_event} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to record processed stripe event",
|
||||
event_id: event_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Customer Events
|
||||
defp handle_customer_created(customer_data) do
|
||||
create_account_from_stripe_customer(customer_data)
|
||||
end
|
||||
|
||||
defp handle_customer_updated(customer_data) do
|
||||
%{
|
||||
"id" => customer_id,
|
||||
"name" => customer_name,
|
||||
"email" => customer_email,
|
||||
"metadata" => customer_metadata
|
||||
} = customer_data
|
||||
|
||||
attrs = build_customer_update_attrs(customer_metadata, customer_name, customer_email)
|
||||
|
||||
case update_account_by_stripe_customer_id(customer_id, attrs) do
|
||||
{:ok, _account} -> :ok
|
||||
{:error, :customer_not_provisioned} -> create_account_from_stripe_customer(customer_data)
|
||||
# TODO: Find out when this would happen and why
|
||||
{:error, :not_found} -> :ok
|
||||
{:error, reason} -> log_and_return_error("sync account from Stripe", customer_id, reason)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_customer_deleted(customer_data) do
|
||||
%{"id" => customer_id} = customer_data
|
||||
|
||||
disable_account_attrs = %{
|
||||
disabled_at: DateTime.utc_now(),
|
||||
disabled_reason: "Stripe customer deleted"
|
||||
}
|
||||
|
||||
update_account(customer_id, disable_account_attrs)
|
||||
end
|
||||
|
||||
# Subscription Events
|
||||
defp handle_subscription_deleted(subscription_data) do
|
||||
customer_id = Map.get(subscription_data, "customer")
|
||||
|
||||
disable_account_attrs = %{
|
||||
disabled_at: DateTime.utc_now(),
|
||||
disabled_reason: "Stripe subscription deleted"
|
||||
}
|
||||
|
||||
update_account(customer_id, disable_account_attrs)
|
||||
end
|
||||
|
||||
defp handle_subscription_paused(subscription_data) do
|
||||
customer_id = Map.get(subscription_data, "customer")
|
||||
|
||||
disable_account_attrs = %{
|
||||
disabled_at: DateTime.utc_now(),
|
||||
disabled_reason: "Stripe subscription paused"
|
||||
}
|
||||
|
||||
update_account(customer_id, disable_account_attrs)
|
||||
end
|
||||
|
||||
defp handle_subscription_updated(subscription_data) do
|
||||
case get_in(subscription_data, ["pause_collection", "behavior"]) do
|
||||
"void" ->
|
||||
# paused subscription
|
||||
handle_subscription_paused(subscription_data)
|
||||
|
||||
_ ->
|
||||
# regular subscription update
|
||||
handle_subscription_active(subscription_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_subscription_active(subscription_data) do
|
||||
customer_id = Map.get(subscription_data, "customer")
|
||||
|
||||
with {:ok, attrs} <- build_subscription_update_attrs(subscription_data) do
|
||||
update_account(customer_id, attrs)
|
||||
else
|
||||
{:error, reason} ->
|
||||
log_and_return_error("build subscription update attrs", customer_id, reason)
|
||||
end
|
||||
end
|
||||
|
||||
# We don't care about any other type of stripe events at this point
|
||||
defp handle_unknown_event(_event_type, _event_data), do: :ok
|
||||
|
||||
defp build_customer_update_attrs(customer_metadata, customer_name, customer_email) do
|
||||
%{
|
||||
name: customer_metadata["account_name"] || customer_name,
|
||||
legal_name: customer_name,
|
||||
metadata: %{stripe: %{billing_email: customer_email}}
|
||||
}
|
||||
|> put_if_not_nil(:slug, customer_metadata["account_slug"])
|
||||
end
|
||||
|
||||
defp build_subscription_update_attrs(subscription_data) do
|
||||
%{
|
||||
"id" => subscription_id,
|
||||
"customer" => _customer_id,
|
||||
"metadata" => subscription_metadata,
|
||||
"trial_end" => trial_end,
|
||||
"status" => status,
|
||||
"items" => %{"data" => [%{"price" => %{"product" => product_id}, "quantity" => quantity}]}
|
||||
} = subscription_data
|
||||
|
||||
with {:ok, product_info} <- Billing.fetch_product(product_id) do
|
||||
%{"name" => product_name, "metadata" => product_metadata} = product_info
|
||||
|
||||
subscription_trialing? = not is_nil(trial_end) and status in ["trialing", "paused"]
|
||||
|
||||
stripe_metadata = %{
|
||||
"subscription_id" => subscription_id,
|
||||
"product_name" => product_name,
|
||||
"trial_ends_at" => if(subscription_trialing?, do: DateTime.from_unix!(trial_end))
|
||||
}
|
||||
|
||||
attrs =
|
||||
account_update_attrs(
|
||||
quantity,
|
||||
product_metadata,
|
||||
subscription_metadata,
|
||||
stripe_metadata
|
||||
)
|
||||
|> Map.put(:disabled_at, nil)
|
||||
|> Map.put(:disabled_reason, nil)
|
||||
|
||||
{:ok, attrs}
|
||||
else
|
||||
{:error, :retry_later} ->
|
||||
{:error, :fetch_product_failed}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_account(customer_id, attrs) do
|
||||
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
|
||||
log_and_return_error("update account on Stripe subscription event", customer_id, reason)
|
||||
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
|
||||
defp log_and_return_error(operation, customer_id, reason) do
|
||||
Logger.error("Failed to #{operation}",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
:ok =
|
||||
Logger.error("Failed to update account on Stripe subscription event",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
|
||||
:error
|
||||
end
|
||||
:error
|
||||
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,
|
||||
"trial_end" => trial_end,
|
||||
"status" => status,
|
||||
"items" => %{
|
||||
"data" => [
|
||||
%{
|
||||
"plan" => %{
|
||||
"product" => product_id
|
||||
},
|
||||
"quantity" => quantity
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"type" => "customer.subscription." <> event_type
|
||||
})
|
||||
when event_type in [
|
||||
"created",
|
||||
"resumed",
|
||||
"updated"
|
||||
] do
|
||||
{:ok,
|
||||
%{
|
||||
"name" => product_name,
|
||||
"metadata" => product_metadata
|
||||
}} = Billing.fetch_product(product_id)
|
||||
|
||||
subscription_trialing? = not is_nil(trial_end) and status in ["trialing", "paused"]
|
||||
|
||||
attrs =
|
||||
account_update_attrs(quantity, product_metadata, subscription_metadata, %{
|
||||
"subscription_id" => subscription_id,
|
||||
"product_name" => product_name,
|
||||
"trial_ends_at" => if(subscription_trialing?, do: DateTime.from_unix!(trial_end))
|
||||
})
|
||||
|> 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
|
||||
# Account Creation
|
||||
|
||||
defp create_account_from_stripe_customer(%{"metadata" => %{"account_id" => _account_id}}) do
|
||||
:ok
|
||||
@@ -222,37 +319,12 @@ defmodule Domain.Billing.EventHandler do
|
||||
"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
|
||||
not is_nil(metadata["account_slug"]) ->
|
||||
metadata["account_slug"]
|
||||
|
||||
uri.host ->
|
||||
uri.host
|
||||
|> String.split(".")
|
||||
|> List.delete_at(-1)
|
||||
|> Enum.join("_")
|
||||
|> String.replace("-", "_")
|
||||
|
||||
uri.path ->
|
||||
uri.path
|
||||
|> String.split(".")
|
||||
|> List.delete_at(-1)
|
||||
|> Enum.join("_")
|
||||
|> String.replace("-", "_")
|
||||
|
||||
true ->
|
||||
Accounts.generate_unique_slug()
|
||||
end
|
||||
"metadata" => metadata
|
||||
})
|
||||
when is_map_key(metadata, "company_website") and
|
||||
is_map_key(metadata, "account_owner_first_name") and
|
||||
is_map_key(metadata, "account_owner_last_name") do
|
||||
account_slug = generate_account_slug(metadata)
|
||||
|
||||
attrs = %{
|
||||
name: metadata["account_name"] || customer_name,
|
||||
@@ -266,74 +338,20 @@ defmodule Domain.Billing.EventHandler do
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
# TODO: IDP Sync
|
||||
# Use special case everyone group instead of storing in DB
|
||||
{:ok, _everyone_group} =
|
||||
Domain.Actors.create_managed_group(account, %{
|
||||
name: "Everyone"
|
||||
})
|
||||
|
||||
{:ok, email_provider} =
|
||||
Domain.Auth.create_provider(account, %{
|
||||
name: "Email (OTP)",
|
||||
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, email_provider, %{
|
||||
provider_identifier: metadata["account_admin_email"] || account_email,
|
||||
provider_identifier_confirmation: metadata["account_admin_email"] || account_email
|
||||
})
|
||||
|
||||
{:ok, _gateway_group} = Domain.Gateways.create_group(account, %{name: "Default Site"})
|
||||
|
||||
{:ok, internet_gateway_group} = Domain.Gateways.create_internet_group(account)
|
||||
|
||||
{:ok, _resource} =
|
||||
Domain.Resources.create_internet_resource(account, internet_gateway_group)
|
||||
|
||||
:ok
|
||||
else
|
||||
{:error, %Ecto.Changeset{errors: [{:slug, {"has already been taken", _}} | _]}} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, _} ->
|
||||
case create_account_with_defaults(attrs, metadata, account_email) 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, %Ecto.Changeset{errors: [{:slug, {"has already been taken", _}} | _]}} ->
|
||||
{:error, :slug_taken}
|
||||
|
||||
{:error, reason} ->
|
||||
:ok =
|
||||
Logger.error("Failed to create account from Stripe",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
Logger.error("Failed to create account from Stripe",
|
||||
customer_id: customer_id,
|
||||
reason: inspect(reason)
|
||||
)
|
||||
|
||||
:error
|
||||
{:error, :failed_account_creation}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -342,17 +360,97 @@ defmodule Domain.Billing.EventHandler do
|
||||
"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"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
{:error, :missing_custom_metadata}
|
||||
end
|
||||
|
||||
defp generate_account_slug(metadata) do
|
||||
cond do
|
||||
not is_nil(metadata["account_slug"]) ->
|
||||
metadata["account_slug"]
|
||||
|
||||
company_website = metadata["company_website"] ->
|
||||
extract_slug_from_uri(company_website)
|
||||
|
||||
true ->
|
||||
Accounts.generate_unique_slug()
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_slug_from_uri(company_website) do
|
||||
uri = URI.parse(company_website)
|
||||
|
||||
cond do
|
||||
uri.host ->
|
||||
uri.host
|
||||
|> String.split(".")
|
||||
|> List.delete_at(-1)
|
||||
|> Enum.join("_")
|
||||
|> String.replace("-", "_")
|
||||
|
||||
uri.path ->
|
||||
uri.path
|
||||
|> String.split(".")
|
||||
|> List.delete_at(-1)
|
||||
|> Enum.join("_")
|
||||
|> String.replace("-", "_")
|
||||
|
||||
true ->
|
||||
Accounts.generate_unique_slug()
|
||||
end
|
||||
end
|
||||
|
||||
defp create_account_with_defaults(attrs, metadata, account_email) do
|
||||
with {:ok, account} <- Domain.Accounts.create_account(attrs),
|
||||
{:ok, account} <- Billing.update_stripe_customer(account),
|
||||
{:ok, account} <- Domain.Billing.create_subscription(account),
|
||||
:ok <- setup_account_defaults(account, metadata, account_email) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp setup_account_defaults(account, metadata, account_email) do
|
||||
# Create default groups and resources
|
||||
# TODO: IDP Sync - Use special case everyone group instead of storing in DB
|
||||
{:ok, _everyone_group} = Domain.Actors.create_managed_group(account, %{name: "Everyone"})
|
||||
{:ok, _gateway_group} = Domain.Gateways.create_group(account, %{name: "Default Site"})
|
||||
{:ok, internet_gateway_group} = Domain.Gateways.create_internet_group(account)
|
||||
{:ok, _resource} = Domain.Resources.create_internet_resource(account, internet_gateway_group)
|
||||
|
||||
# Create email provider
|
||||
{:ok, email_provider} =
|
||||
Domain.Auth.create_provider(account, %{
|
||||
name: "Email (OTP)",
|
||||
adapter: :email,
|
||||
adapter_config: %{}
|
||||
})
|
||||
|
||||
# Create admin user
|
||||
admin_name = "#{metadata["account_owner_first_name"]} #{metadata["account_owner_last_name"]}"
|
||||
admin_email = metadata["account_admin_email"] || account_email
|
||||
|
||||
{:ok, actor} =
|
||||
Domain.Actors.create_actor(account, %{
|
||||
type: :account_admin_user,
|
||||
name: admin_name
|
||||
})
|
||||
|
||||
{:ok, _identity} =
|
||||
Domain.Auth.upsert_identity(actor, email_provider, %{
|
||||
provider_identifier: admin_email,
|
||||
provider_identifier_confirmation: admin_email
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# Account Updates
|
||||
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)
|
||||
@@ -360,50 +458,59 @@ defmodule Domain.Billing.EventHandler do
|
||||
end
|
||||
|
||||
defp account_update_attrs(
|
||||
quantity,
|
||||
seats,
|
||||
product_metadata,
|
||||
subscription_metadata,
|
||||
stripe_metadata_overrides
|
||||
stripe_metadata
|
||||
) do
|
||||
# feature_fields = Accounts.Features.__schema__(:fields) |> Enum.map(&to_string/1)
|
||||
limit_fields = Accounts.Limits.__schema__(:fields) |> Enum.map(&to_string/1)
|
||||
feature_fields = Accounts.Features.__schema__(:fields) |> Enum.map(&to_string/1)
|
||||
metadata_fields = ["support_type"]
|
||||
|
||||
params =
|
||||
Map.merge(product_metadata, subscription_metadata)
|
||||
|> Enum.flat_map(fn
|
||||
{feature, "true"} ->
|
||||
[{feature, true}]
|
||||
|> parse_metadata_params(limit_fields, metadata_fields)
|
||||
|
||||
{feature, "false"} ->
|
||||
[{feature, false}]
|
||||
|
||||
{key, value} ->
|
||||
cond do
|
||||
key in limit_fields ->
|
||||
[{key, cast_limit(value)}]
|
||||
|
||||
key in metadata_fields ->
|
||||
[{key, value}]
|
||||
|
||||
true ->
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|
||||
{users_count, params} = Map.pop(params, "users_count", quantity)
|
||||
{users_count, params} = Map.pop(params, "users_count", seats)
|
||||
{metadata, params} = Map.split(params, metadata_fields)
|
||||
{limits, features} = Map.split(params, limit_fields)
|
||||
{limits, params} = Map.split(params, limit_fields)
|
||||
{features, _} = Map.split(params, feature_fields)
|
||||
|
||||
limits = Map.merge(limits, %{"users_count" => users_count})
|
||||
|
||||
%{
|
||||
features: features,
|
||||
limits: limits,
|
||||
metadata: %{stripe: Map.merge(metadata, stripe_metadata_overrides)}
|
||||
metadata: %{stripe: Map.merge(metadata, stripe_metadata)}
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_metadata_params(metadata, limit_fields, metadata_fields) do
|
||||
metadata
|
||||
|> Enum.flat_map(fn
|
||||
{feature, "true"} ->
|
||||
[{feature, true}]
|
||||
|
||||
{feature, "false"} ->
|
||||
[{feature, false}]
|
||||
|
||||
{feature, true} ->
|
||||
[{feature, true}]
|
||||
|
||||
{feature, false} ->
|
||||
[{feature, false}]
|
||||
|
||||
{key, value} ->
|
||||
cond do
|
||||
key in limit_fields -> [{key, cast_limit(value)}]
|
||||
key in metadata_fields -> [{key, value}]
|
||||
true -> []
|
||||
end
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
end
|
||||
|
||||
# Utility Functions
|
||||
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)
|
||||
|
||||
@@ -131,6 +131,7 @@ defmodule Domain.Billing.Stripe.APIClient do
|
||||
"Rate limited by Stripe API (429), retrying request.",
|
||||
request_delay: "#{delay}ms",
|
||||
attempt_num: "#{attempt + 1} of #{max_retries}",
|
||||
url_path: path,
|
||||
response: inspect(response)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
defmodule Domain.Billing.Stripe.ProcessedEvents do
|
||||
@moduledoc """
|
||||
Context for tracking processed Stripe webhook events.
|
||||
Provides idempotency and chronological ordering capabilities.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Domain.Repo
|
||||
alias Domain.Billing.Stripe.ProcessedEvents.ProcessedEvent
|
||||
|
||||
@doc """
|
||||
Checks if a Stripe event has already been processed by event ID.
|
||||
"""
|
||||
def event_processed?(stripe_event_id) do
|
||||
ProcessedEvent.Query.all()
|
||||
|> ProcessedEvent.Query.by_event_id(stripe_event_id)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a processed event by Stripe event ID.
|
||||
"""
|
||||
def get_by_stripe_event_id(stripe_event_id) do
|
||||
ProcessedEvent.Query.all()
|
||||
|> ProcessedEvent.Query.by_event_id(stripe_event_id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a processed event record.
|
||||
"""
|
||||
def create_processed_event(attrs \\ %{}) do
|
||||
%ProcessedEvent{}
|
||||
|> ProcessedEvent.Changeset.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the latest processed event for a customer (by Stripe customer ID).
|
||||
"""
|
||||
def get_latest_for_stripe_customer(nil), do: nil
|
||||
|
||||
def get_latest_for_stripe_customer(stripe_customer_id) do
|
||||
ProcessedEvent.Query.all()
|
||||
|> ProcessedEvent.Query.by_latest_event(stripe_customer_id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the latest processed event for a Stripe customer by event type.
|
||||
"""
|
||||
def get_latest_for_stripe_customer(nil, _event_type), do: nil
|
||||
|
||||
def get_latest_for_stripe_customer(customer_id, event_type) do
|
||||
ProcessedEvent.Query.all()
|
||||
|> ProcessedEvent.Query.by_latest_event_type(customer_id, event_type)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up processed events older than specified days.
|
||||
"""
|
||||
def cleanup_old_events(days_old \\ 30) do
|
||||
cutoff_date = DateTime.utc_now() |> DateTime.add(-days_old, :day)
|
||||
|
||||
{count, _} =
|
||||
ProcessedEvent.Query.all()
|
||||
|> ProcessedEvent.Query.by_cutoff_date(cutoff_date)
|
||||
|> Repo.delete_all()
|
||||
|
||||
{:ok, count}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Billing.Stripe.ProcessedEvents.ProcessedEvent do
|
||||
@moduledoc """
|
||||
Schema for tracking processed Stripe webhook events.
|
||||
Ensures idempotency and chronological ordering of event processing.
|
||||
"""
|
||||
|
||||
use Domain, :schema
|
||||
|
||||
@primary_key {:stripe_event_id, :string, []}
|
||||
schema "processed_stripe_events" do
|
||||
field :event_type, :string
|
||||
field :processed_at, :utc_datetime_usec
|
||||
field :stripe_customer_id, :string
|
||||
field :event_created_at, :utc_datetime
|
||||
field :livemode, :boolean, default: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
defmodule Domain.Billing.Stripe.ProcessedEvents.ProcessedEvent.Changeset do
|
||||
use Domain, :changeset
|
||||
|
||||
@required_fields [
|
||||
:stripe_event_id,
|
||||
:event_type,
|
||||
:processed_at,
|
||||
:event_created_at,
|
||||
:livemode
|
||||
]
|
||||
|
||||
@optional_fields [
|
||||
:stripe_customer_id
|
||||
]
|
||||
def changeset(processed_event, attrs) do
|
||||
processed_event
|
||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_length(:stripe_event_id, max: 255)
|
||||
|> validate_length(:event_type, max: 100)
|
||||
|> validate_length(:stripe_customer_id, max: 255)
|
||||
|> validate_format(:stripe_event_id, ~r/^evt_/, message: "is not a valid event id")
|
||||
|> validate_format(:stripe_customer_id, ~r/^cus_/, message: "is not a valid customer id")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
defmodule Domain.Billing.Stripe.ProcessedEvents.ProcessedEvent.Query do
|
||||
use Domain, :query
|
||||
alias Domain.Billing.Stripe.ProcessedEvents.ProcessedEvent
|
||||
|
||||
def all do
|
||||
from(events in ProcessedEvent, as: :events)
|
||||
end
|
||||
|
||||
def by_event_id(queryable, id) do
|
||||
where(queryable, [events: events], events.stripe_event_id == ^id)
|
||||
end
|
||||
|
||||
def by_event_type(queryable, event_type) do
|
||||
where(queryable, [events: events], events.event_type == ^event_type)
|
||||
end
|
||||
|
||||
def by_stripe_customer_id(queryable, stripe_customer_id) do
|
||||
where(queryable, [events: events], events.stripe_customer_id == ^stripe_customer_id)
|
||||
end
|
||||
|
||||
def by_latest_event(queryable, stripe_customer_id) do
|
||||
by_stripe_customer_id(queryable, stripe_customer_id)
|
||||
|> order_by([events: events], desc: events.event_created_at)
|
||||
|> limit(1)
|
||||
end
|
||||
|
||||
def by_latest_event_type(queryable, stripe_customer_id, event_type) do
|
||||
by_stripe_customer_id(queryable, stripe_customer_id)
|
||||
|> by_event_type(event_type)
|
||||
|> order_by([events: events], desc: events.event_created_at)
|
||||
|> limit(1)
|
||||
end
|
||||
|
||||
def by_cutoff_date(queryable, cutoff_date) do
|
||||
where(queryable, [events: events], events.processed_at < ^cutoff_date)
|
||||
end
|
||||
end
|
||||
@@ -43,7 +43,7 @@ defmodule Domain.MixProject do
|
||||
defp deps do
|
||||
[
|
||||
# Ecto-related deps
|
||||
{:postgrex, "~> 0.19"},
|
||||
{:postgrex, "~> 0.20"},
|
||||
{:decimal, "~> 2.0"},
|
||||
{:ecto_sql, "~> 3.7"},
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
defmodule Domain.Repo.Migrations.CreateProcessedStripeEvents do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:processed_stripe_events, primary_key: false) do
|
||||
add(:stripe_event_id, :string, null: false, primary_key: true)
|
||||
add(:event_type, :string, null: false)
|
||||
add(:processed_at, :utc_datetime, null: false, default: fragment("NOW()"))
|
||||
add(:stripe_customer_id, :string, null: true)
|
||||
add(:event_created_at, :utc_datetime, null: false)
|
||||
add(:livemode, :boolean, null: false, default: false)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
create(index(:processed_stripe_events, [:stripe_customer_id, :event_type]))
|
||||
create(index(:processed_stripe_events, [:stripe_customer_id, :event_created_at]))
|
||||
end
|
||||
|
||||
def down do
|
||||
execute("DROP TABLE IF EXISTS processed_stripe_events")
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ defmodule Domain.BillingTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Billing
|
||||
alias Domain.Billing
|
||||
alias Domain.Billing.Stripe.ProcessedEvents
|
||||
alias Domain.Mocks.Stripe
|
||||
|
||||
setup do
|
||||
@@ -33,7 +34,7 @@ defmodule Domain.BillingTest do
|
||||
test "returns true when account is provisioned", %{account: account} do
|
||||
{:ok, account} =
|
||||
Domain.Accounts.update_account(account, %{
|
||||
metadata: %{stripe: %{customer_id: Ecto.UUID.generate()}}
|
||||
metadata: %{stripe: %{customer_id: "cus_" <> Stripe.random_id()}}
|
||||
})
|
||||
|
||||
assert account_provisioned?(account) == true
|
||||
@@ -387,7 +388,7 @@ defmodule Domain.BillingTest do
|
||||
test "does nothing when account is already provisioned", %{account: account} do
|
||||
{:ok, account} =
|
||||
Domain.Accounts.update_account(account, %{
|
||||
metadata: %{stripe: %{customer_id: Ecto.UUID.generate()}}
|
||||
metadata: %{stripe: %{customer_id: "cus_" <> Stripe.random_id()}}
|
||||
})
|
||||
|
||||
assert provision_account(account) == {:ok, account}
|
||||
@@ -439,7 +440,7 @@ defmodule Domain.BillingTest do
|
||||
|
||||
describe "handle_events/1" do
|
||||
setup %{account: account} do
|
||||
customer_id = "cus_" <> Ecto.UUID.generate()
|
||||
customer_id = "cus_" <> Stripe.random_id()
|
||||
|
||||
{:ok, account} =
|
||||
Domain.Accounts.update_account(account, %{
|
||||
@@ -459,47 +460,36 @@ defmodule Domain.BillingTest do
|
||||
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",
|
||||
%{}
|
||||
)
|
||||
)
|
||||
account_name = "FooBarCompany"
|
||||
customer = Stripe.build_customer(name: account_name, email: "foo@bar.com")
|
||||
event = Stripe.build_event("customer.created", customer)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
assert Repo.aggregate(Domain.Accounts.Account, :count) == 1
|
||||
refute Repo.get_by(Domain.Accounts.Account, name: account_name)
|
||||
end
|
||||
|
||||
test "does nothing on customer.created event when metadata has account_id" do
|
||||
customer_id = "cus_" <> Ecto.UUID.generate()
|
||||
|
||||
event =
|
||||
Stripe.build_event(
|
||||
"customer.created",
|
||||
Stripe.customer_object(
|
||||
customer_id,
|
||||
"New Account Name",
|
||||
"iown@bigcompany.com",
|
||||
%{"account_id" => Ecto.UUID.generate()}
|
||||
)
|
||||
test "does nothing on customer.created event when metadata has account_id", %{
|
||||
account: account
|
||||
} do
|
||||
customer =
|
||||
Stripe.build_customer(
|
||||
name: "FooBarCompany",
|
||||
email: "foo@bar.com",
|
||||
metadata: %{"account_id" => account.id}
|
||||
)
|
||||
|
||||
event = Stripe.build_event("customer.created", customer)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
assert Repo.aggregate(Domain.Accounts.Account, :count) == 1
|
||||
refute Repo.get_by(Domain.Accounts.Account, name: "FooBarCompany")
|
||||
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()
|
||||
customer_id = "cus_" <> Stripe.random_id()
|
||||
|
||||
Bypass.open()
|
||||
|> Stripe.mock_update_customer_endpoint(%{
|
||||
@@ -563,6 +553,45 @@ defmodule Domain.BillingTest do
|
||||
}
|
||||
end
|
||||
|
||||
test "ignores replay of customer.created event" do
|
||||
Domain.Config.put_env_override(:outbound_email_adapter_configured?, true)
|
||||
|
||||
customer_id = "cus_" <> Stripe.random_id()
|
||||
|
||||
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 Repo.get_by(Domain.Accounts.Account, slug: "bigcompany")
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
assert Domain.Accounts.Account.Query.all()
|
||||
|> Domain.Accounts.Account.Query.by_slug("bigcompany")
|
||||
|> Repo.all()
|
||||
|> length() == 1
|
||||
end
|
||||
|
||||
test "does nothing on customer.created event when an account already exists", %{
|
||||
account: account
|
||||
} do
|
||||
@@ -586,7 +615,7 @@ defmodule Domain.BillingTest do
|
||||
end
|
||||
|
||||
test "does nothing on customer.updated event when metadata is incomplete" do
|
||||
customer_id = "cus_" <> Ecto.UUID.generate()
|
||||
customer_id = "cus_" <> Stripe.random_id()
|
||||
|
||||
customer_mock_attrs = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
@@ -618,7 +647,7 @@ defmodule Domain.BillingTest do
|
||||
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_id = "cus_" <> Stripe.random_id()
|
||||
|
||||
customer_mock_attrs = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
@@ -704,11 +733,10 @@ defmodule Domain.BillingTest do
|
||||
} do
|
||||
Bypass.open() |> Stripe.mock_fetch_customer_endpoint(account)
|
||||
|
||||
{_product, _price, subscription} = Stripe.build_all(:team, customer_id, 0)
|
||||
|
||||
event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.deleted",
|
||||
Stripe.subscription_object(customer_id, %{}, %{}, 0)
|
||||
)
|
||||
Stripe.build_event("customer.subscription.deleted", subscription)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
@@ -717,18 +745,16 @@ defmodule Domain.BillingTest do
|
||||
assert account.disabled_reason == "Stripe subscription deleted"
|
||||
end
|
||||
|
||||
test "disables the account on when subscription is paused (updated event)", %{
|
||||
test "disables the account when subscription is paused (updated event)", %{
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
} do
|
||||
Bypass.open() |> Stripe.mock_fetch_customer_endpoint(account)
|
||||
|
||||
event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
Stripe.subscription_object(customer_id, %{}, %{}, 0)
|
||||
|> Map.put("pause_collection", %{"behavior" => "void"})
|
||||
)
|
||||
{_product, _price, subscription} = Stripe.build_all(:team, customer_id, 0)
|
||||
subscription = Stripe.pause_subscription(subscription)
|
||||
|
||||
event = Stripe.build_event("customer.subscription.updated", subscription)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
@@ -741,105 +767,151 @@ defmodule Domain.BillingTest do
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
} do
|
||||
Bypass.open()
|
||||
|> Stripe.mock_fetch_customer_endpoint(account)
|
||||
|> Stripe.mock_fetch_product_endpoint("prod_Na6dGcTsmU0I4R")
|
||||
{product, _price, subscription} = Stripe.build_all(:team, customer_id, 5)
|
||||
|
||||
{:ok, account} =
|
||||
Domain.Accounts.update_account(account, %{
|
||||
disabled_at: DateTime.utc_now(),
|
||||
disabled_reason: "Stripe subscription paused"
|
||||
})
|
||||
|
||||
event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
Stripe.subscription_object(customer_id, %{}, %{}, 0)
|
||||
customer =
|
||||
Stripe.build_customer(
|
||||
id: customer_id,
|
||||
email: "foo@bar.com",
|
||||
name: account.name,
|
||||
metadata: %{account_id: account.id}
|
||||
)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
Bypass.open()
|
||||
|> Stripe.fetch_customer_endpoint(customer)
|
||||
|> Stripe.fetch_product_endpoint(product)
|
||||
|
||||
subscription = Stripe.pause_subscription(subscription)
|
||||
|
||||
pause_event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
subscription,
|
||||
System.os_time(:second) - 30
|
||||
)
|
||||
|
||||
assert handle_events([pause_event]) == :ok
|
||||
|
||||
# Verify that account is disabled
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
assert account.disabled_at
|
||||
assert account.disabled_reason == "Stripe subscription paused"
|
||||
|
||||
subscription = Stripe.resume_subscription(subscription)
|
||||
continue_event = Stripe.build_event("customer.subscription.updated", subscription)
|
||||
|
||||
assert handle_events([continue_event]) == :ok
|
||||
|
||||
# Verify that account has been re-enabled
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
assert account.disabled_at == nil
|
||||
assert account.disabled_reason == nil
|
||||
end
|
||||
|
||||
test "updates account on subscription update", %{
|
||||
test "updates account from starter to team on subscription update", %{
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
} do
|
||||
# Set account to 'Starter' limits
|
||||
account =
|
||||
Fixtures.Accounts.update_account(account, %{
|
||||
limits: %{
|
||||
service_accounts_count: 10101
|
||||
},
|
||||
features: %{
|
||||
traffic_filters: true
|
||||
account_admin_users_count: 1,
|
||||
gateway_groups_count: 10,
|
||||
service_accounts_count: 10,
|
||||
users_count: 6
|
||||
}
|
||||
})
|
||||
|
||||
Bypass.open()
|
||||
|> Stripe.mock_fetch_customer_endpoint(account)
|
||||
|> Stripe.mock_fetch_product_endpoint("prod_Na6dGcTsmU0I4R", %{
|
||||
metadata: %{
|
||||
"multi_site_resources" => "false",
|
||||
"self_hosted_relays" => "true",
|
||||
"monthly_active_users_count" => "15",
|
||||
"service_accounts_count" => "unlimited",
|
||||
"gateway_groups_count" => 1,
|
||||
"users_count" => 14,
|
||||
"support_type" => "email"
|
||||
}
|
||||
})
|
||||
|
||||
subscription_metadata = %{
|
||||
"idp_sync" => "true",
|
||||
"multi_site_resources" => "true",
|
||||
"traffic_filters" => "false",
|
||||
"gateway_groups_count" => 5
|
||||
}
|
||||
|
||||
quantity = 13
|
||||
|
||||
trial_ends_at =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(2, :day)
|
||||
|
||||
event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
Stripe.subscription_object(customer_id, subscription_metadata, %{}, quantity)
|
||||
|> Map.put("trial_end", DateTime.to_unix(trial_ends_at))
|
||||
|> Map.put("status", "trialing")
|
||||
customer =
|
||||
Stripe.build_customer(
|
||||
id: customer_id,
|
||||
email: "foo@bar.com",
|
||||
name: account.name,
|
||||
metadata: %{account_id: account.id}
|
||||
)
|
||||
|
||||
quantity = 15
|
||||
|
||||
{product, _price, subscription} =
|
||||
Stripe.build_all(:team, customer_id, quantity)
|
||||
|
||||
Bypass.open()
|
||||
|> Stripe.fetch_customer_endpoint(customer)
|
||||
|> Stripe.fetch_product_endpoint(product)
|
||||
|
||||
event = Stripe.build_event("customer.subscription.updated", subscription)
|
||||
|
||||
assert handle_events([event]) == :ok
|
||||
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
|
||||
assert account.metadata.stripe.customer_id == customer_id
|
||||
assert account.metadata.stripe.subscription_id
|
||||
assert account.metadata.stripe.product_name == "Enterprise"
|
||||
assert account.metadata.stripe.subscription_id == subscription["id"]
|
||||
assert account.metadata.stripe.product_name == "Team"
|
||||
assert account.metadata.stripe.support_type == "email"
|
||||
|
||||
assert DateTime.truncate(account.metadata.stripe.trial_ends_at, :second) ==
|
||||
DateTime.truncate(trial_ends_at, :second)
|
||||
|
||||
assert account.limits == %Domain.Accounts.Limits{
|
||||
monthly_active_users_count: 15,
|
||||
gateway_groups_count: 5,
|
||||
service_accounts_count: nil,
|
||||
users_count: 14
|
||||
account_admin_users_count: 10,
|
||||
gateway_groups_count: 100,
|
||||
service_accounts_count: 100,
|
||||
users_count: 15
|
||||
}
|
||||
|
||||
assert account.features == %Domain.Accounts.Features{
|
||||
idp_sync: true,
|
||||
multi_site_resources: true,
|
||||
self_hosted_relays: true,
|
||||
traffic_filters: false
|
||||
internet_resource: true,
|
||||
policy_conditions: true,
|
||||
traffic_filters: true
|
||||
}
|
||||
end
|
||||
|
||||
test "ignores older subscription update events", %{
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
} do
|
||||
{_product, _price, starter_subscription} = Stripe.build_all(:starter, customer_id, 1)
|
||||
{team_product, _price, team_subscription} = Stripe.build_all(:team, customer_id, 5)
|
||||
|
||||
Bypass.open()
|
||||
|> Stripe.mock_fetch_customer_endpoint(account)
|
||||
|> Stripe.fetch_product_endpoint(team_product)
|
||||
|
||||
starter_sub_update_time = System.os_time(:second) - 30
|
||||
team_sub_update_time = System.os_time(:second)
|
||||
|
||||
starter_event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
starter_subscription,
|
||||
starter_sub_update_time
|
||||
)
|
||||
|
||||
team_event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
team_subscription,
|
||||
team_sub_update_time
|
||||
)
|
||||
|
||||
assert handle_events([team_event]) == :ok
|
||||
assert Repo.all(ProcessedEvents.ProcessedEvent) |> length() == 1
|
||||
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
|
||||
assert account.metadata.stripe.customer_id == customer_id
|
||||
assert account.metadata.stripe.subscription_id
|
||||
assert account.metadata.stripe.product_name == "Team"
|
||||
assert account.metadata.stripe.support_type == "email"
|
||||
|
||||
assert handle_events([starter_event]) == :ok
|
||||
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
|
||||
# Account metadata should not have been changed since starter event was in the past
|
||||
assert account.metadata.stripe.product_name == "Team"
|
||||
assert account.metadata.stripe.support_type == "email"
|
||||
end
|
||||
|
||||
test "resets trial ended when subscription becomes active", %{
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
@@ -986,5 +1058,64 @@ defmodule Domain.BillingTest do
|
||||
assert account_after_expired_event = Repo.get(Domain.Accounts.Account, account.id)
|
||||
assert account_after_expired_event == account
|
||||
end
|
||||
|
||||
test "allows processing of older events from different types", %{
|
||||
account: account,
|
||||
customer_id: customer_id
|
||||
} do
|
||||
orig_account = account
|
||||
{team_product, _price, team_subscription} = Stripe.build_all(:team, customer_id, 5)
|
||||
|
||||
updated_customer =
|
||||
Stripe.build_customer(
|
||||
id: customer_id,
|
||||
name: "Updated Customer",
|
||||
email: "updated@customer.com",
|
||||
metadata: %{
|
||||
"account_id" => account.id,
|
||||
"account_name" => account.name
|
||||
}
|
||||
)
|
||||
|
||||
Bypass.open()
|
||||
|> Stripe.fetch_customer_endpoint(updated_customer)
|
||||
|> Stripe.fetch_product_endpoint(team_product)
|
||||
|
||||
customer_update_time = System.os_time(:second) - 30
|
||||
team_sub_update_time = System.os_time(:second)
|
||||
|
||||
customer_update_event =
|
||||
Stripe.build_event(
|
||||
"customer.updated",
|
||||
updated_customer,
|
||||
customer_update_time
|
||||
)
|
||||
|
||||
team_event =
|
||||
Stripe.build_event(
|
||||
"customer.subscription.updated",
|
||||
team_subscription,
|
||||
team_sub_update_time
|
||||
)
|
||||
|
||||
assert handle_events([team_event]) == :ok
|
||||
assert Repo.all(ProcessedEvents.ProcessedEvent) |> length() == 1
|
||||
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
|
||||
assert account.metadata.stripe.customer_id == customer_id
|
||||
assert account.metadata.stripe.subscription_id == team_subscription["id"]
|
||||
assert account.metadata.stripe.product_name == "Team"
|
||||
assert account.metadata.stripe.support_type == "email"
|
||||
assert account.legal_name == orig_account.legal_name
|
||||
|
||||
assert handle_events([customer_update_event]) == :ok
|
||||
|
||||
assert account = Repo.get(Domain.Accounts.Account, account.id)
|
||||
|
||||
assert account.metadata.stripe.product_name == "Team"
|
||||
assert account.metadata.stripe.support_type == "email"
|
||||
assert account.legal_name == updated_customer["name"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule Domain.Mocks.Stripe do
|
||||
alias Domain.Billing.Stripe.APIClient
|
||||
|
||||
@charset Enum.to_list(?A..?Z) ++ Enum.to_list(?a..?z) ++ Enum.to_list(?0..?9)
|
||||
|
||||
def override_endpoint_url(url) do
|
||||
config = Domain.Config.fetch_env!(:domain, APIClient)
|
||||
config = Keyword.put(config, :endpoint, url)
|
||||
@@ -109,9 +111,65 @@ defmodule Domain.Mocks.Stripe do
|
||||
bypass
|
||||
end
|
||||
|
||||
def fetch_customer_endpoint(bypass, customer) do
|
||||
customer_endpoint_path = "v1/customers/#{customer["id"]}"
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", customer_endpoint_path, fn conn ->
|
||||
conn = %{conn | private: Map.put(conn.private, :test_pid, test_pid)}
|
||||
|
||||
case check_rate_limit(conn) do
|
||||
{:rate_limited, response} ->
|
||||
response
|
||||
|
||||
:ok ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(customer))
|
||||
end
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}")
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def fetch_product_endpoint(bypass, product) do
|
||||
product_endpoint_path = "v1/products/#{product["id"]}"
|
||||
test_pid = self()
|
||||
|
||||
Bypass.expect(bypass, "GET", product_endpoint_path, fn conn ->
|
||||
conn = %{conn | private: Map.put(conn.private, :test_pid, test_pid)}
|
||||
|
||||
case check_rate_limit(conn) do
|
||||
{:rate_limited, response} ->
|
||||
response
|
||||
|
||||
:ok ->
|
||||
conn = Plug.Conn.fetch_query_params(conn)
|
||||
send(test_pid, {:bypass_request, conn})
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(product))
|
||||
end
|
||||
end)
|
||||
|
||||
override_endpoint_url("http://localhost:#{bypass.port}")
|
||||
|
||||
bypass
|
||||
end
|
||||
|
||||
def mock_fetch_product_endpoint(bypass, product_id, resp \\ %{}) do
|
||||
product_endpoint_path = "v1/products/#{product_id}"
|
||||
|
||||
# %{
|
||||
# "active" => true,
|
||||
# "created" => 1678833149,
|
||||
# "default_price" => nil,
|
||||
# "description" => nil,
|
||||
# "id" => "prod_vGU4ZPE2f3XFmkH8f8KwI2QJ",
|
||||
# "name" => "Team Plan",
|
||||
# "object" => "product"
|
||||
# }
|
||||
|
||||
resp =
|
||||
Map.merge(
|
||||
%{
|
||||
@@ -553,12 +611,49 @@ defmodule Domain.Mocks.Stripe do
|
||||
}
|
||||
end
|
||||
|
||||
def build_event(type, object) do
|
||||
def build_customer(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"id" => "evt_1NG8Du2eZvKYlo2CUI79vXWy",
|
||||
"id" => "cus_" <> random_id(),
|
||||
"object" => "customer",
|
||||
"address" => nil,
|
||||
"balance" => 0,
|
||||
"created" => 1_680_893_993,
|
||||
"currency" => nil,
|
||||
"default_source" => nil,
|
||||
"delinquent" => false,
|
||||
"description" => nil,
|
||||
"discount" => nil,
|
||||
"email" => "foo@bar.com",
|
||||
"invoice_prefix" => "0759376C",
|
||||
"invoice_settings" => %{
|
||||
"custom_fields" => nil,
|
||||
"default_payment_method" => nil,
|
||||
"footer" => nil,
|
||||
"rendering_options" => nil
|
||||
},
|
||||
"livemode" => false,
|
||||
"metadata" => %{},
|
||||
"name" => "John Doe",
|
||||
"next_invoice_sequence" => 1,
|
||||
"phone" => nil,
|
||||
"preferred_locales" => [],
|
||||
"shipping" => nil,
|
||||
"tax_exempt" => "none",
|
||||
"test_clock" => nil
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def build_event(type, object, created_at \\ nil) do
|
||||
created_at = created_at || DateTime.now!("Etc/UTC") |> DateTime.to_unix()
|
||||
|
||||
%{
|
||||
"id" => "evt_#{random_id()}",
|
||||
"object" => "event",
|
||||
"api_version" => "2019-02-19",
|
||||
"created" => 1_686_089_970,
|
||||
"created" => created_at,
|
||||
"data" => %{
|
||||
"object" => object
|
||||
},
|
||||
@@ -572,6 +667,188 @@ defmodule Domain.Mocks.Stripe do
|
||||
}
|
||||
end
|
||||
|
||||
def build_all(type, customer_id, seats, metadata \\ %{})
|
||||
|
||||
def build_all(:starter, customer_id, seats, metadata) do
|
||||
product = build_product(name: "Starter", metadata: starter_metadata())
|
||||
price = build_price(product: product["id"], amount: 0)
|
||||
|
||||
subscription =
|
||||
build_subscription(
|
||||
customer: customer_id,
|
||||
metadata: metadata,
|
||||
items: [[price: price, quantity: seats]]
|
||||
)
|
||||
|
||||
{product, price, subscription}
|
||||
end
|
||||
|
||||
def build_all(:team, customer_id, seats, metadata) do
|
||||
product = build_product(name: "Team", metadata: team_metadata())
|
||||
price = build_price(product: product["id"], amount: 500)
|
||||
|
||||
subscription =
|
||||
build_subscription(
|
||||
customer: customer_id,
|
||||
metadata: metadata,
|
||||
items: [[price: price, quantity: seats]]
|
||||
)
|
||||
|
||||
{product, price, subscription}
|
||||
end
|
||||
|
||||
def build_all(:enterprise, customer_id, seats, metadata) do
|
||||
product = build_product(name: "Enterprise", metadata: enterprise_metadata())
|
||||
price = build_price(product: product["id"], amount: 1000)
|
||||
|
||||
subscription =
|
||||
build_subscription(
|
||||
customer: customer_id,
|
||||
metadata: metadata,
|
||||
items: [[price: price, quantity: seats]]
|
||||
)
|
||||
|
||||
{product, price, subscription}
|
||||
end
|
||||
|
||||
def build_subscription(opts \\ []) do
|
||||
sub_items =
|
||||
for item <- opts[:items] do
|
||||
build_subscription_item(item)
|
||||
end
|
||||
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
items = %{"object" => "list", "data" => sub_items}
|
||||
opts = %{opts | "items" => items}
|
||||
|
||||
%{
|
||||
"id" => "sub_" <> random_id(),
|
||||
"object" => "subscription",
|
||||
"status" => "active",
|
||||
"customer" => "cus_" <> random_id(),
|
||||
"items" => %{
|
||||
"object" => "list",
|
||||
"data" => []
|
||||
},
|
||||
"metadata" => %{},
|
||||
"pause_collection" => nil,
|
||||
"trial_end" => nil,
|
||||
"trial_start" => nil
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def build_subscription_item(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"id" => "si_" <> random_id(),
|
||||
"object" => "subscription_item",
|
||||
"current_period_start" => System.os_time(:second),
|
||||
"current_period_end" => System.os_time(:second) + duration(30, :day),
|
||||
"price" => build_price(),
|
||||
"quantity" => 1
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def build_price(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"id" => "price_" <> random_id(),
|
||||
"object" => "price",
|
||||
"active" => true,
|
||||
"currency" => "usd",
|
||||
"unit_amount" => 500,
|
||||
"recurring" => %{
|
||||
"interval" => "month",
|
||||
"interval_count" => 1,
|
||||
"trial_period_days" => nil
|
||||
},
|
||||
"product" => "prod_" <> random_id()
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def build_product(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
default_values = %{
|
||||
"id" => "prod_" <> random_id(),
|
||||
"object" => "product",
|
||||
"active" => true,
|
||||
"created" => 1_678_833_149,
|
||||
"default_price" => nil,
|
||||
"description" => nil,
|
||||
"name" => "Team",
|
||||
"metadata" => team_metadata()
|
||||
}
|
||||
|
||||
Map.merge(default_values, opts)
|
||||
end
|
||||
|
||||
def starter_metadata(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"account_admin_users_count" => 1,
|
||||
"gateway_groups_count" => 10,
|
||||
"monthly_active_users_count" => "unlimited",
|
||||
"service_accounts_count" => 10,
|
||||
"users_count" => 6
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def team_metadata(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"account_admin_users_count" => 10,
|
||||
"gateway_groups_count" => 100,
|
||||
"internet_resource" => true,
|
||||
"monthly_active_users_count" => "unlimited",
|
||||
"policy_conditions" => true,
|
||||
"service_accounts_count" => 100,
|
||||
"support_type" => "email",
|
||||
"traffic_filters" => true
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def enterprise_metadata(opts \\ []) do
|
||||
opts = normalize_opts(opts)
|
||||
|
||||
%{
|
||||
"account_admin_users_count" => "unlimited",
|
||||
"gateway_groups_count" => "unlimited",
|
||||
"idp_sync" => true,
|
||||
"internet_resource" => true,
|
||||
"monthly_active_users_count" => "unlimited",
|
||||
"policy_conditions" => true,
|
||||
"rest_api" => true,
|
||||
"service_accounts_count" => "unlimited",
|
||||
"support_type" => "email_and_slack",
|
||||
"traffic_filters" => true,
|
||||
"users_count" => "unlimited"
|
||||
}
|
||||
|> Map.merge(opts)
|
||||
end
|
||||
|
||||
def pause_subscription(subscription) do
|
||||
Map.put(subscription, "pause_collection", %{"behavior" => "void"})
|
||||
end
|
||||
|
||||
def resume_subscription(subscription) do
|
||||
Map.put(subscription, "pause_collection", nil)
|
||||
end
|
||||
|
||||
def random_id(length \\ 24) do
|
||||
for _ <- 1..length, into: "", do: <<Enum.random(@charset)>>
|
||||
end
|
||||
|
||||
defp fetch_request_params(conn) do
|
||||
opts =
|
||||
Plug.Parsers.init(
|
||||
@@ -582,4 +859,27 @@ defmodule Domain.Mocks.Stripe do
|
||||
|
||||
Plug.Parsers.call(conn, opts)
|
||||
end
|
||||
|
||||
defp duration(length, unit) do
|
||||
case unit do
|
||||
:second -> length
|
||||
:hour -> length * 60 * 60
|
||||
:day -> length * 60 * 60 * 24
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_opts(opts) when is_list(opts) do
|
||||
opts
|
||||
|> Enum.into(%{})
|
||||
|> stringify_keys()
|
||||
end
|
||||
|
||||
defp normalize_opts(opts) when is_map(opts), do: stringify_keys(opts)
|
||||
|
||||
defp stringify_keys(map) do
|
||||
for {key, val} <- map, into: %{} do
|
||||
{to_string(key), val}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,7 +21,8 @@ config :domain, Domain.ComponentVersions,
|
||||
config :domain, Domain.Billing,
|
||||
enabled: System.get_env("BILLING_ENABLED", "false") == "true",
|
||||
secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_dev_1111"),
|
||||
webhook_signing_secret: System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET", "whsec_dev_1111")
|
||||
webhook_signing_secret: System.get_env("STRIPE_WEBHOOK_SIGNING_SECRET", "whsec_dev_1111"),
|
||||
default_price_id: System.get_env("STRIPE_DEFAULT_PRICE_ID", "price_1OkUIcADeNU9NGxvTNA4PPq6")
|
||||
|
||||
# Oban has its own config validation that prevents overriding config in runtime.exs,
|
||||
# so we explicitly set the config in dev.exs, test.exs, and runtime.exs (for prod) only.
|
||||
|
||||
Reference in New Issue
Block a user