From 80e1c3255f788f27faf5d6b5e0bc63c68cf1975b Mon Sep 17 00:00:00 2001 From: Brian Manifold Date: Tue, 5 Aug 2025 09:56:52 -0700 Subject: [PATCH] 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 --- elixir/apps/domain/lib/domain/billing.ex | 4 +- .../lib/domain/billing/event_handler.ex | 751 ++++++++++-------- .../lib/domain/billing/stripe/api_client.ex | 1 + .../domain/billing/stripe/processed_events.ex | 73 ++ .../processed_events/processed_event.ex | 19 + .../processed_event/changeset.ex | 25 + .../processed_events/processed_event/query.ex | 37 + elixir/apps/domain/mix.exs | 2 +- ...5152831_create_processed_stripe_events.exs | 23 + .../apps/domain/test/domain/billing_test.exs | 347 +++++--- .../apps/domain/test/support/mocks/stripe.ex | 306 ++++++- elixir/config/dev.exs | 3 +- 12 files changed, 1154 insertions(+), 437 deletions(-) create mode 100644 elixir/apps/domain/lib/domain/billing/stripe/processed_events.ex create mode 100644 elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event.ex create mode 100644 elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/changeset.ex create mode 100644 elixir/apps/domain/lib/domain/billing/stripe/processed_events/processed_event/query.ex create mode 100644 elixir/apps/domain/priv/repo/migrations/20250805152831_create_processed_stripe_events.exs 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.