diff --git a/elixir/apps/domain/lib/domain/billing.ex b/elixir/apps/domain/lib/domain/billing.ex index 9ceca29f8..a87663426 100644 --- a/elixir/apps/domain/lib/domain/billing.ex +++ b/elixir/apps/domain/lib/domain/billing.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/billing/event_handler.ex b/elixir/apps/domain/lib/domain/billing/event_handler.ex index 0e69e8b08..028ac9cd9 100644 --- a/elixir/apps/domain/lib/domain/billing/event_handler.ex +++ b/elixir/apps/domain/lib/domain/billing/event_handler.ex @@ -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) diff --git a/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex index 23b2ffdfe..40f973974 100644 --- a/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex +++ b/elixir/apps/domain/lib/domain/billing/stripe/api_client.ex @@ -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) ) diff --git a/elixir/apps/domain/lib/domain/billing/stripe/processed_events.ex b/elixir/apps/domain/lib/domain/billing/stripe/processed_events.ex new file mode 100644 index 000000000..052db72a4 --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/stripe/processed_events.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event.ex b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event.ex new file mode 100644 index 000000000..181460315 --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/changeset.ex b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/changeset.ex new file mode 100644 index 000000000..a103d2425 --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/changeset.ex @@ -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 diff --git a/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/query.ex b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/query.ex new file mode 100644 index 000000000..0efc42fdf --- /dev/null +++ b/elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/query.ex @@ -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 diff --git a/elixir/apps/domain/mix.exs b/elixir/apps/domain/mix.exs index c8e765ebe..e6431b1f4 100644 --- a/elixir/apps/domain/mix.exs +++ b/elixir/apps/domain/mix.exs @@ -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"}, diff --git a/elixir/apps/domain/priv/repo/migrations/20250805152831_create_processed_stripe_events.exs b/elixir/apps/domain/priv/repo/migrations/20250805152831_create_processed_stripe_events.exs new file mode 100644 index 000000000..770e29645 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250805152831_create_processed_stripe_events.exs @@ -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 diff --git a/elixir/apps/domain/test/domain/billing_test.exs b/elixir/apps/domain/test/domain/billing_test.exs index 9cf604662..87c3af7fc 100644 --- a/elixir/apps/domain/test/domain/billing_test.exs +++ b/elixir/apps/domain/test/domain/billing_test.exs @@ -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 diff --git a/elixir/apps/domain/test/support/mocks/stripe.ex b/elixir/apps/domain/test/support/mocks/stripe.ex index dc6175213..c049ccee4 100644 --- a/elixir/apps/domain/test/support/mocks/stripe.ex +++ b/elixir/apps/domain/test/support/mocks/stripe.ex @@ -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: <> + 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 diff --git a/elixir/config/dev.exs b/elixir/config/dev.exs index 11c24c9b3..2eebd2db4 100644 --- a/elixir/config/dev.exs +++ b/elixir/config/dev.exs @@ -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.