refactor(portal): refactor billing event handler (#10064)

Why:

* There were intermittent issues with accounts updates from Stripe
events. Specifically, when an account would update it's subscription
from Starter to Team. The reason was due to the fact that Stripe does
not guarantee order of delivery for it's webhook events. At times we
were seeing and responding to an event that was a few seconds old after
processing a newer event. This would have the effect of quickly
transitioning an account from Team back to Starter. This commit
refactors our event handler and adds a `processed_stripe_events` DB
table to make sure we don't process duplicate events as well as prevent
processing an event that was created prior to the last event we've
processed for a given account.

* Along with refactoring the billing event handling, the Stripe mock
module has also been refactored to better reflect real Stripe objects.

Related: #8668
This commit is contained in:
Brian Manifold
2025-08-05 09:56:52 -07:00
committed by GitHub
parent ea960cce74
commit 80e1c3255f
12 changed files with 1154 additions and 437 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
defmodule Domain.Mocks.Stripe do
alias Domain.Billing.Stripe.APIClient
@charset Enum.to_list(?A..?Z) ++ Enum.to_list(?a..?z) ++ Enum.to_list(?0..?9)
def override_endpoint_url(url) do
config = Domain.Config.fetch_env!(:domain, APIClient)
config = Keyword.put(config, :endpoint, url)
@@ -109,9 +111,65 @@ defmodule Domain.Mocks.Stripe do
bypass
end
def fetch_customer_endpoint(bypass, customer) do
customer_endpoint_path = "v1/customers/#{customer["id"]}"
test_pid = self()
Bypass.expect(bypass, "GET", customer_endpoint_path, fn conn ->
conn = %{conn | private: Map.put(conn.private, :test_pid, test_pid)}
case check_rate_limit(conn) do
{:rate_limited, response} ->
response
:ok ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(customer))
end
end)
override_endpoint_url("http://localhost:#{bypass.port}")
bypass
end
def fetch_product_endpoint(bypass, product) do
product_endpoint_path = "v1/products/#{product["id"]}"
test_pid = self()
Bypass.expect(bypass, "GET", product_endpoint_path, fn conn ->
conn = %{conn | private: Map.put(conn.private, :test_pid, test_pid)}
case check_rate_limit(conn) do
{:rate_limited, response} ->
response
:ok ->
conn = Plug.Conn.fetch_query_params(conn)
send(test_pid, {:bypass_request, conn})
Plug.Conn.send_resp(conn, 200, Jason.encode!(product))
end
end)
override_endpoint_url("http://localhost:#{bypass.port}")
bypass
end
def mock_fetch_product_endpoint(bypass, product_id, resp \\ %{}) do
product_endpoint_path = "v1/products/#{product_id}"
# %{
# "active" => true,
# "created" => 1678833149,
# "default_price" => nil,
# "description" => nil,
# "id" => "prod_vGU4ZPE2f3XFmkH8f8KwI2QJ",
# "name" => "Team Plan",
# "object" => "product"
# }
resp =
Map.merge(
%{
@@ -553,12 +611,49 @@ defmodule Domain.Mocks.Stripe do
}
end
def build_event(type, object) do
def build_customer(opts \\ []) do
opts = normalize_opts(opts)
%{
"id" => "evt_1NG8Du2eZvKYlo2CUI79vXWy",
"id" => "cus_" <> random_id(),
"object" => "customer",
"address" => nil,
"balance" => 0,
"created" => 1_680_893_993,
"currency" => nil,
"default_source" => nil,
"delinquent" => false,
"description" => nil,
"discount" => nil,
"email" => "foo@bar.com",
"invoice_prefix" => "0759376C",
"invoice_settings" => %{
"custom_fields" => nil,
"default_payment_method" => nil,
"footer" => nil,
"rendering_options" => nil
},
"livemode" => false,
"metadata" => %{},
"name" => "John Doe",
"next_invoice_sequence" => 1,
"phone" => nil,
"preferred_locales" => [],
"shipping" => nil,
"tax_exempt" => "none",
"test_clock" => nil
}
|> Map.merge(opts)
end
def build_event(type, object, created_at \\ nil) do
created_at = created_at || DateTime.now!("Etc/UTC") |> DateTime.to_unix()
%{
"id" => "evt_#{random_id()}",
"object" => "event",
"api_version" => "2019-02-19",
"created" => 1_686_089_970,
"created" => created_at,
"data" => %{
"object" => object
},
@@ -572,6 +667,188 @@ defmodule Domain.Mocks.Stripe do
}
end
def build_all(type, customer_id, seats, metadata \\ %{})
def build_all(:starter, customer_id, seats, metadata) do
product = build_product(name: "Starter", metadata: starter_metadata())
price = build_price(product: product["id"], amount: 0)
subscription =
build_subscription(
customer: customer_id,
metadata: metadata,
items: [[price: price, quantity: seats]]
)
{product, price, subscription}
end
def build_all(:team, customer_id, seats, metadata) do
product = build_product(name: "Team", metadata: team_metadata())
price = build_price(product: product["id"], amount: 500)
subscription =
build_subscription(
customer: customer_id,
metadata: metadata,
items: [[price: price, quantity: seats]]
)
{product, price, subscription}
end
def build_all(:enterprise, customer_id, seats, metadata) do
product = build_product(name: "Enterprise", metadata: enterprise_metadata())
price = build_price(product: product["id"], amount: 1000)
subscription =
build_subscription(
customer: customer_id,
metadata: metadata,
items: [[price: price, quantity: seats]]
)
{product, price, subscription}
end
def build_subscription(opts \\ []) do
sub_items =
for item <- opts[:items] do
build_subscription_item(item)
end
opts = normalize_opts(opts)
items = %{"object" => "list", "data" => sub_items}
opts = %{opts | "items" => items}
%{
"id" => "sub_" <> random_id(),
"object" => "subscription",
"status" => "active",
"customer" => "cus_" <> random_id(),
"items" => %{
"object" => "list",
"data" => []
},
"metadata" => %{},
"pause_collection" => nil,
"trial_end" => nil,
"trial_start" => nil
}
|> Map.merge(opts)
end
def build_subscription_item(opts \\ []) do
opts = normalize_opts(opts)
%{
"id" => "si_" <> random_id(),
"object" => "subscription_item",
"current_period_start" => System.os_time(:second),
"current_period_end" => System.os_time(:second) + duration(30, :day),
"price" => build_price(),
"quantity" => 1
}
|> Map.merge(opts)
end
def build_price(opts \\ []) do
opts = normalize_opts(opts)
%{
"id" => "price_" <> random_id(),
"object" => "price",
"active" => true,
"currency" => "usd",
"unit_amount" => 500,
"recurring" => %{
"interval" => "month",
"interval_count" => 1,
"trial_period_days" => nil
},
"product" => "prod_" <> random_id()
}
|> Map.merge(opts)
end
def build_product(opts \\ []) do
opts = normalize_opts(opts)
default_values = %{
"id" => "prod_" <> random_id(),
"object" => "product",
"active" => true,
"created" => 1_678_833_149,
"default_price" => nil,
"description" => nil,
"name" => "Team",
"metadata" => team_metadata()
}
Map.merge(default_values, opts)
end
def starter_metadata(opts \\ []) do
opts = normalize_opts(opts)
%{
"account_admin_users_count" => 1,
"gateway_groups_count" => 10,
"monthly_active_users_count" => "unlimited",
"service_accounts_count" => 10,
"users_count" => 6
}
|> Map.merge(opts)
end
def team_metadata(opts \\ []) do
opts = normalize_opts(opts)
%{
"account_admin_users_count" => 10,
"gateway_groups_count" => 100,
"internet_resource" => true,
"monthly_active_users_count" => "unlimited",
"policy_conditions" => true,
"service_accounts_count" => 100,
"support_type" => "email",
"traffic_filters" => true
}
|> Map.merge(opts)
end
def enterprise_metadata(opts \\ []) do
opts = normalize_opts(opts)
%{
"account_admin_users_count" => "unlimited",
"gateway_groups_count" => "unlimited",
"idp_sync" => true,
"internet_resource" => true,
"monthly_active_users_count" => "unlimited",
"policy_conditions" => true,
"rest_api" => true,
"service_accounts_count" => "unlimited",
"support_type" => "email_and_slack",
"traffic_filters" => true,
"users_count" => "unlimited"
}
|> Map.merge(opts)
end
def pause_subscription(subscription) do
Map.put(subscription, "pause_collection", %{"behavior" => "void"})
end
def resume_subscription(subscription) do
Map.put(subscription, "pause_collection", nil)
end
def random_id(length \\ 24) do
for _ <- 1..length, into: "", do: <<Enum.random(@charset)>>
end
defp fetch_request_params(conn) do
opts =
Plug.Parsers.init(
@@ -582,4 +859,27 @@ defmodule Domain.Mocks.Stripe do
Plug.Parsers.call(conn, opts)
end
defp duration(length, unit) do
case unit do
:second -> length
:hour -> length * 60 * 60
:day -> length * 60 * 60 * 24
_ -> nil
end
end
defp normalize_opts(opts) when is_list(opts) do
opts
|> Enum.into(%{})
|> stringify_keys()
end
defp normalize_opts(opts) when is_map(opts), do: stringify_keys(opts)
defp stringify_keys(map) do
for {key, val} <- map, into: %{} do
{to_string(key), val}
end
end
end

View File

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