diff --git a/config/.env.dev b/config/.env.dev
index 92b855456..cb957fe12 100644
--- a/config/.env.dev
+++ b/config/.env.dev
@@ -29,4 +29,9 @@ S3_ENDPOINT=http://localhost:10000
S3_EXPORTS_BUCKET=dev-exports
S3_IMPORTS_BUCKET=dev-imports
+HELP_SCOUT_APP_ID=fake_app_id
+HELP_SCOUT_APP_SECRET=fake_app_secret
+HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
+HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
+
VERIFICATION_ENABLED=true
diff --git a/config/.env.test b/config/.env.test
index 322bcdc60..f9098926b 100644
--- a/config/.env.test
+++ b/config/.env.test
@@ -15,6 +15,10 @@ IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb
SITE_DEFAULT_INGEST_THRESHOLD=1000000
GOOGLE_CLIENT_ID=fake_client_id
GOOGLE_CLIENT_SECRET=fake_client_secret
+HELP_SCOUT_APP_ID=fake_app_id
+HELP_SCOUT_APP_SECRET=fake_app_secret
+HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
+HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
S3_DISABLED=false
S3_ACCESS_KEY_ID=minioadmin
diff --git a/config/runtime.exs b/config/runtime.exs
index e54dc7fd1..02b9f11fc 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -207,6 +207,10 @@ paddle_vendor_id = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_ID")
google_cid = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_ID")
google_secret = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_SECRET")
postmark_api_key = get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY")
+help_scout_app_id = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_ID")
+help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SECRET")
+help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY")
+help_scout_vault_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_VAULT_KEY")
{otel_sampler_ratio, ""} =
config_dir
@@ -372,6 +376,12 @@ config :plausible, :google,
api_url: "https://www.googleapis.com",
reporting_api_url: "https://analyticsreporting.googleapis.com"
+config :plausible, Plausible.HelpScout,
+ app_id: help_scout_app_id,
+ app_secret: help_scout_app_secret,
+ signature_key: help_scout_signature_key,
+ vault_key: help_scout_vault_key
+
config :plausible, :imported,
max_buffer_size: get_int_from_path_or_env(config_dir, "IMPORTED_MAX_BUFFER_SIZE", 10_000)
diff --git a/config/test.exs b/config/test.exs
index 110c9965c..004264858 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -41,3 +41,8 @@ config :plausible, Plausible.Verification.Checks.Installation,
req_opts: [
plug: {Req.Test, Plausible.Verification.Checks.Installation}
]
+
+config :plausible, Plausible.HelpScout,
+ req_opts: [
+ plug: {Req.Test, Plausible.HelpScout}
+ ]
diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex
new file mode 100644
index 000000000..3005246c7
--- /dev/null
+++ b/extra/lib/plausible/help_scout.ex
@@ -0,0 +1,260 @@
+defmodule Plausible.HelpScout do
+ @moduledoc """
+ HelpScout callback API logic.
+ """
+
+ import Ecto.Query
+
+ alias Plausible.Billing
+ alias Plausible.Billing.Subscription
+ alias Plausible.HelpScout.Vault
+ alias Plausible.Repo
+
+ alias PlausibleWeb.Router.Helpers, as: Routes
+
+ require Plausible.Billing.Subscription.Status
+
+ @base_api_url "https://api.helpscout.net"
+ @signature_field "X-HelpScout-Signature"
+
+ @doc """
+ Validates signature against secret key configured for the
+ HelpScout application.
+
+ NOTE: HelpScout signature generation procedure at
+ https://developer.helpscout.com/apps/guides/signature-validation/
+ fails to mention that it's implicitly dependent on request params
+ order getting preserved. PHP arrays are ordered maps, so they provide
+ this guarantee. Here, on the other hand, we have to determine the original
+ order of the keys directly from the query string and serialize
+ params to JSON using wrapper struct, informing Jason to put the values
+ in the serialized object in this particular order matching query string.
+ """
+ @spec validate_signature(Plug.Conn.t()) :: :ok | {:error, :missing_signature | :bad_signature}
+ def validate_signature(conn) do
+ params = conn.params
+
+ keys =
+ conn.query_string
+ |> String.split("&")
+ |> Enum.map(fn part ->
+ part |> String.split("=") |> List.first()
+ end)
+ |> Enum.reject(&(&1 == @signature_field))
+
+ signature = params[@signature_field]
+
+ if is_binary(signature) do
+ signature_key = Keyword.fetch!(config(), :signature_key)
+
+ ordered_data = Enum.map(keys, fn key -> {key, params[key]} end)
+ data = Jason.encode!(%Jason.OrderedObject{values: ordered_data})
+
+ calculated =
+ :hmac
+ |> :crypto.mac(:sha, signature_key, data)
+ |> Base.encode64()
+
+ if Plug.Crypto.secure_compare(signature, calculated) do
+ :ok
+ else
+ {:error, :bad_signature}
+ end
+ else
+ {:error, :missing_signature}
+ end
+ end
+
+ @spec get_customer_details(String.t()) :: {:ok, map()} | {:error, any()}
+ def get_customer_details(customer_id) do
+ with {:ok, emails} <- get_customer_emails(customer_id),
+ {:ok, user} <- get_user(emails) do
+ user = Plausible.Users.with_subscription(user.id)
+ plan = Billing.Plans.get_subscription_plan(user.subscription)
+
+ {:ok,
+ %{
+ status_label: status_label(user),
+ status_link:
+ Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
+ plan_label: plan_label(user.subscription, plan),
+ plan_link: plan_link(user.subscription)
+ }}
+ end
+ end
+
+ defp plan_link(nil), do: "#"
+
+ defp plan_link(%{paddle_subscription_id: paddle_id}) do
+ Path.join([
+ Billing.PaddleApi.vendors_domain(),
+ "/subscriptions/customers/manage/",
+ paddle_id
+ ])
+ end
+
+ defp status_label(user) do
+ subscription_active? = Billing.Subscriptions.active?(user.subscription)
+ trial? = Plausible.Users.on_trial?(user)
+
+ cond do
+ not subscription_active? and not trial? and is_nil(user.trial_expiry_date) ->
+ "None"
+
+ is_nil(user.subscription) and not trial? ->
+ "Expired trial"
+
+ trial? ->
+ "Trial"
+
+ user.subscription.status == Subscription.Status.deleted() ->
+ if subscription_active? do
+ "Pending cancellation"
+ else
+ "Canceled"
+ end
+
+ user.subscription.status == Subscription.Status.paused() ->
+ "Paused"
+
+ Plausible.Sites.owned_sites_locked?(user) ->
+ "Dashboard locked"
+
+ subscription_active? ->
+ "Paid"
+ end
+ end
+
+ defp plan_label(_, nil) do
+ "None"
+ end
+
+ defp plan_label(_, :free_10k) do
+ "Free 10k"
+ end
+
+ defp plan_label(subscription, %Billing.Plan{} = plan) do
+ [plan] = Billing.Plans.with_prices([plan])
+ interval = Billing.Plans.subscription_interval(subscription)
+ quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])
+
+ price =
+ cond do
+ interval == "monthly" && plan.monthly_cost ->
+ Billing.format_price(plan.monthly_cost)
+
+ interval == "yearly" && plan.yearly_cost ->
+ Billing.format_price(plan.yearly_cost)
+
+ true ->
+ "N/A"
+ end
+
+ "#{quota} Plan (#{price} #{interval})"
+ end
+
+ defp plan_label(subscription, %Billing.EnterprisePlan{} = plan) do
+ quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])
+ price_amount = Billing.Plans.get_price_for(plan, "127.0.0.1")
+
+ price =
+ if price_amount do
+ Billing.format_price(price_amount)
+ else
+ "N/A"
+ end
+
+ "#{quota} Enterprise Plan (#{price} #{plan.billing_interval})"
+ end
+
+ defp get_user(emails) do
+ user =
+ from(u in Plausible.Auth.User, where: u.email in ^emails, limit: 1)
+ |> Repo.one()
+
+ if user do
+ {:ok, user}
+ else
+ {:error, :not_found}
+ end
+ end
+
+ defp get_customer_emails(customer_id, opts \\ []) do
+ refresh? = Keyword.get(opts, :refresh?, true)
+ token = get_token!()
+
+ url = Path.join([@base_api_url, "/v2/customers/", customer_id])
+
+ extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
+ opts = Keyword.merge([auth: {:bearer, token}], extra_opts)
+
+ case Req.get(url, opts) do
+ {:ok, %{body: %{"_embedded" => %{"emails" => [_ | _] = emails}}}} ->
+ {:ok, Enum.map(emails, & &1["value"])}
+
+ {:ok, %{status: 200}} ->
+ {:error, :no_emails}
+
+ {:ok, %{status: 404}} ->
+ {:error, :not_found}
+
+ {:ok, %{status: 401}} ->
+ if refresh? do
+ refresh_token!()
+ get_customer_emails(customer_id, refresh?: false)
+ else
+ {:error, :auth_failed}
+ end
+
+ error ->
+ Sentry.capture_message("Failed to obtain customer data from HelpScout API",
+ extra: %{error: inspect(error), customer_id: customer_id}
+ )
+
+ {:error, :unknown}
+ end
+ end
+
+ defp get_token!() do
+ token =
+ "SELECT access_token FROM help_scout_credentials ORDER BY id DESC LIMIT 1"
+ |> Repo.query!()
+ |> Map.get(:rows)
+ |> List.first()
+
+ case token do
+ [token] when is_binary(token) ->
+ Vault.decrypt!(token)
+
+ _ ->
+ refresh_token!()
+ end
+ end
+
+ defp refresh_token!() do
+ url = Path.join(@base_api_url, "/v2/oauth2/token")
+ credentials = config()
+
+ params = [
+ grant_type: "client_credentials",
+ client_id: Keyword.fetch!(credentials, :app_id),
+ client_secret: Keyword.fetch!(credentials, :app_secret)
+ ]
+
+ extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
+ opts = Keyword.merge([form: params], extra_opts)
+
+ %{status: 200, body: %{"access_token" => token}} = Req.post!(url, opts)
+ now = NaiveDateTime.utc_now(:second)
+
+ Repo.insert_all("help_scout_credentials", [
+ [access_token: Vault.encrypt!(token), inserted_at: now, updated_at: now]
+ ])
+
+ token
+ end
+
+ defp config() do
+ Application.fetch_env!(:plausible, __MODULE__)
+ end
+end
diff --git a/extra/lib/plausible/help_scout/vault.ex b/extra/lib/plausible/help_scout/vault.ex
new file mode 100644
index 000000000..ef4017ff5
--- /dev/null
+++ b/extra/lib/plausible/help_scout/vault.ex
@@ -0,0 +1,19 @@
+defmodule Plausible.HelpScout.Vault do
+ @moduledoc """
+ Provides a vault that will be used to encrypt/decrypt the stored HelpScout API access tokens.
+ """
+
+ use Cloak.Vault, otp_app: :plausible
+
+ @impl GenServer
+ def init(config) do
+ {key, config} = Keyword.pop!(config, :key)
+
+ config =
+ Keyword.put(config, :ciphers,
+ default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", iv_length: 12, key: key}
+ )
+
+ {:ok, config}
+ end
+end
diff --git a/extra/lib/plausible_web/controllers/help_scout_controller.ex b/extra/lib/plausible_web/controllers/help_scout_controller.ex
new file mode 100644
index 000000000..1015a5712
--- /dev/null
+++ b/extra/lib/plausible_web/controllers/help_scout_controller.ex
@@ -0,0 +1,20 @@
+defmodule PlausibleWeb.HelpScoutController do
+ use PlausibleWeb, :controller
+
+ alias Plausible.HelpScout
+
+ def callback(conn, %{"customer-id" => customer_id}) do
+ conn =
+ conn
+ |> delete_resp_header("x-frame-options")
+ |> put_layout(false)
+
+ with :ok <- HelpScout.validate_signature(conn),
+ {:ok, details} <- HelpScout.get_customer_details(customer_id) do
+ render(conn, "callback.html", details)
+ else
+ {:error, error} ->
+ render(conn, "callback.html", error: inspect(error))
+ end
+ end
+end
diff --git a/extra/lib/plausible_web/views/help_scout_view.ex b/extra/lib/plausible_web/views/help_scout_view.ex
new file mode 100644
index 000000000..52794e2da
--- /dev/null
+++ b/extra/lib/plausible_web/views/help_scout_view.ex
@@ -0,0 +1,62 @@
+defmodule PlausibleWeb.HelpScoutView do
+ use PlausibleWeb, :view
+
+ def render("callback.html", assigns) do
+ ~H"""
+
+
+
+
+
+ Helpscout Customer Details
+
+
+
+
+ <%= if @conn.assigns[:error] do %>
+
+ Failed to get details: <%= @error %>
+
+ <% else %>
+
+
+
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex
index 12ba22c31..01eec232d 100644
--- a/lib/plausible/application.ex
+++ b/lib/plausible/application.ex
@@ -93,6 +93,10 @@ defmodule Plausible.Application do
Plausible.PromEx
]
+ on_ee do
+ children = children ++ help_scout_vault()
+ end
+
opts = [strategy: :one_for_one, name: Plausible.Supervisor]
setup_request_logging()
@@ -111,6 +115,18 @@ defmodule Plausible.Application do
:ok
end
+ on_ee do
+ defp help_scout_vault() do
+ help_scout_vault_key =
+ :plausible
+ |> Application.fetch_env!(Plausible.HelpScout)
+ |> Keyword.fetch!(:vault_key)
+ |> Base.decode64!()
+
+ [{Plausible.HelpScout.Vault, key: help_scout_vault_key}]
+ end
+ end
+
defp totp_vault_key() do
:plausible
|> Application.fetch_env!(Plausible.Auth.TOTP)
diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex
index 055c9d55a..901ed2d96 100644
--- a/lib/plausible/billing/plans.ex
+++ b/lib/plausible/billing/plans.ex
@@ -151,7 +151,7 @@ defmodule Plausible.Billing.Plans do
response, fills in the `monthly_cost` and `yearly_cost` fields for each
given plan and returns the new list of plans with completed information.
"""
- def with_prices([_ | _] = plans, customer_ip) do
+ def with_prices([_ | _] = plans, customer_ip \\ "127.0.0.1") do
product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id])
case Plausible.Billing.paddle_api().fetch_prices(product_ids, customer_ip) do
diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex
index 4fbf857ab..a23c72952 100644
--- a/lib/plausible/sites.ex
+++ b/lib/plausible/sites.ex
@@ -338,6 +338,13 @@ defmodule Plausible.Sites do
)
end
+ def owned_sites_locked?(user) do
+ user
+ |> owned_sites_query()
+ |> where([s], s.locked == true)
+ |> Repo.exists?()
+ end
+
def owned_sites_count(user) do
user
|> owned_sites_query()
diff --git a/lib/plausible_release.ex b/lib/plausible_release.ex
index e4c39caf4..9d23382e7 100644
--- a/lib/plausible_release.ex
+++ b/lib/plausible_release.ex
@@ -85,7 +85,7 @@ defmodule Plausible.Release do
plans =
Plausible.Billing.Plans.all()
- |> Plausible.Billing.Plans.with_prices("127.0.0.1")
+ |> Plausible.Billing.Plans.with_prices()
|> Enum.map(fn plan ->
plan = Map.from_struct(plan)
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index ff30122fa..4cdea939c 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -290,6 +290,10 @@ defmodule PlausibleWeb.Router do
get "/auth/google/callback", AuthController, :google_auth_callback
+ on_ee do
+ get "/helpscout/callback", HelpScoutController, :callback
+ end
+
get "/", PageController, :index
get "/billing/change-plan/preview/:plan_id", BillingController, :change_plan_preview
diff --git a/test/plausible/help_scout_test.exs b/test/plausible/help_scout_test.exs
new file mode 100644
index 000000000..fe1467552
--- /dev/null
+++ b/test/plausible/help_scout_test.exs
@@ -0,0 +1,426 @@
+defmodule Plausible.HelpScoutTest do
+ use Plausible.DataCase, async: true
+ use Plausible
+
+ @moduletag :ee_only
+
+ on_ee do
+ alias Plausible.Billing.Subscription
+ alias Plausible.HelpScout
+ alias Plausible.Repo
+
+ require Plausible.Billing.Subscription.Status
+
+ @v4_business_monthly_plan_id "857105"
+ @v4_business_yearly_plan_id "857087"
+
+ describe "validate_signature/1" do
+ test "returns error on missing signature" do
+ conn =
+ :get
+ |> Plug.Test.conn("/?foo=one&bar=two&baz=three")
+ |> Plug.Conn.fetch_query_params()
+
+ assert {:error, :missing_signature} = HelpScout.validate_signature(conn)
+ end
+
+ test "returns error on invalid signature" do
+ conn =
+ :get
+ |> Plug.Test.conn("/?foo=one&bar=two&baz=three&X-HelpScout-Signature=invalid")
+ |> Plug.Conn.fetch_query_params()
+
+ assert {:error, :bad_signature} = HelpScout.validate_signature(conn)
+ end
+
+ test "passes for valid signature" do
+ signature_key = Application.fetch_env!(:plausible, HelpScout)[:signature_key]
+ data = ~s|{"foo":"one","bar":"two","baz":"three"}|
+
+ signature =
+ :hmac
+ |> :crypto.mac(:sha, signature_key, data)
+ |> Base.encode64()
+ |> URI.encode_www_form()
+
+ conn =
+ :get
+ |> Plug.Test.conn("/?foo=one&bar=two&baz=three&X-HelpScout-Signature=#{signature}")
+ |> Plug.Conn.fetch_query_params()
+
+ assert :ok = HelpScout.validate_signature(conn)
+ end
+ end
+
+ describe "get_customer_details/1" do
+ test "returns details for user on trial" do
+ %{id: user_id, email: email} = insert(:user)
+ stub_help_scout_requests(email)
+
+ crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/auth/user/#{user_id}"
+
+ assert {:ok,
+ %{
+ status_link: ^crm_url,
+ status_label: "Trial",
+ plan_link: "#",
+ plan_label: "None"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user without trial or subscription" do
+ %{email: email} = insert(:user, trial_expiry_date: nil)
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "None",
+ plan_link: "#",
+ plan_label: "None"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with trial expired" do
+ %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Expired trial",
+ plan_link: "#",
+ plan_label: "None"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paid subscription on standard plan" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ %{paddle_subscription_id: paddle_subscription_id} =
+ insert(:subscription, user: user, paddle_plan_id: @v4_business_monthly_plan_id)
+
+ stub_help_scout_requests(email)
+
+ plan_link =
+ "https://vendors.paddle.com/subscriptions/customers/manage/#{paddle_subscription_id}"
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paid",
+ plan_link: ^plan_link,
+ plan_label: "10k Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paid subscription on standard yearly plan" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ %{paddle_subscription_id: paddle_subscription_id} =
+ insert(:subscription, user: user, paddle_plan_id: @v4_business_yearly_plan_id)
+
+ stub_help_scout_requests(email)
+
+ plan_link =
+ "https://vendors.paddle.com/subscriptions/customers/manage/#{paddle_subscription_id}"
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paid",
+ plan_link: ^plan_link,
+ plan_label: "10k Plan (€100 yearly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paid subscription on free 10k plan" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ insert(:subscription, user: user, paddle_plan_id: "free_10k")
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paid",
+ plan_link: _,
+ plan_label: "Free 10k"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paid subscription on enterprise plan" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ ep =
+ insert(:enterprise_plan,
+ features: [Plausible.Billing.Feature.StatsAPI],
+ user_id: user.id
+ )
+
+ insert(:subscription, user: user, paddle_plan_id: ep.paddle_plan_id)
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paid",
+ plan_link: _,
+ plan_label: "1M Enterprise Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paid subscription on yearly enterprise plan" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ ep =
+ insert(:enterprise_plan,
+ features: [Plausible.Billing.Feature.StatsAPI],
+ user_id: user.id,
+ billing_interval: :yearly
+ )
+
+ insert(:subscription, user: user, paddle_plan_id: ep.paddle_plan_id)
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paid",
+ plan_link: _,
+ plan_label: "1M Enterprise Plan (€10 yearly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with subscription pending cancellation" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ insert(:subscription,
+ user: user,
+ status: Subscription.Status.deleted(),
+ paddle_plan_id: @v4_business_monthly_plan_id
+ )
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Pending cancellation",
+ plan_link: _,
+ plan_label: "10k Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with canceled subscription" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ insert(:subscription,
+ user: user,
+ status: Subscription.Status.deleted(),
+ paddle_plan_id: @v4_business_monthly_plan_id,
+ next_bill_date: Date.add(Date.utc_today(), -1)
+ )
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Canceled",
+ plan_link: _,
+ plan_label: "10k Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with paused subscription" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ insert(:subscription,
+ user: user,
+ status: Subscription.Status.paused(),
+ paddle_plan_id: @v4_business_monthly_plan_id
+ )
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Paused",
+ plan_link: _,
+ plan_label: "10k Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns for user with locked site" do
+ user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
+
+ insert(:site, members: [user], locked: true)
+
+ insert(:subscription, user: user, paddle_plan_id: @v4_business_monthly_plan_id)
+
+ stub_help_scout_requests(email)
+
+ assert {:ok,
+ %{
+ status_link: _,
+ status_label: "Dashboard locked",
+ plan_link: _,
+ plan_label: "10k Plan (€10 monthly)"
+ }} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns error when no matching user found in database" do
+ insert(:user)
+
+ stub_help_scout_requests("another@example.com")
+
+ assert {:error, :not_found} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns error when no customer found in Help Scout" do
+ Req.Test.stub(HelpScout, fn
+ %{request_path: "/v2/oauth2/token"} = conn ->
+ Req.Test.json(conn, %{
+ "token_type" => "bearer",
+ "access_token" => "369dbb08be58430086d2f8bd832bc1eb",
+ "expires_in" => 172_800
+ })
+
+ %{request_path: "/v2/customers/500"} = conn ->
+ conn
+ |> Plug.Conn.put_status(404)
+ |> Req.Test.text("Not found")
+ end)
+
+ assert {:error, :not_found} = HelpScout.get_customer_details("500")
+ end
+
+ test "returns error when found customer has no emails" do
+ Req.Test.stub(HelpScout, fn
+ %{request_path: "/v2/oauth2/token"} = conn ->
+ Req.Test.json(conn, %{
+ "token_type" => "bearer",
+ "access_token" => "369dbb08be58430086d2f8bd832bc1eb",
+ "expires_in" => 172_800
+ })
+
+ %{request_path: "/v2/customers/500"} = conn ->
+ Req.Test.json(conn, %{
+ "id" => 500,
+ "_embedded" => %{
+ "emails" => []
+ }
+ })
+ end)
+
+ assert {:error, :no_emails} = HelpScout.get_customer_details("500")
+ end
+
+ test "uses existing access token when available" do
+ %{email: email} = insert(:user)
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+
+ Repo.insert_all("help_scout_credentials", [
+ [
+ access_token: HelpScout.Vault.encrypt!("VerySecret"),
+ inserted_at: now,
+ updated_at: now
+ ]
+ ])
+
+ Req.Test.stub(HelpScout, fn %{request_path: "/v2/customers/500"} = conn ->
+ Req.Test.json(conn, %{
+ "id" => 500,
+ "_embedded" => %{
+ "emails" => [
+ %{
+ "id" => 1,
+ "value" => email,
+ "type" => "home"
+ }
+ ]
+ }
+ })
+ end)
+
+ assert {:ok, _} = HelpScout.get_customer_details("500")
+ end
+
+ test "refreshes token on expiry" do
+ %{email: email} = insert(:user)
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+
+ Repo.insert_all("help_scout_credentials", [
+ [
+ access_token: HelpScout.Vault.encrypt!("VerySecretExpired"),
+ inserted_at: now,
+ updated_at: now
+ ]
+ ])
+
+ Req.Test.stub(HelpScout, fn
+ %{request_path: "/v2/oauth2/token"} = conn ->
+ Req.Test.json(conn, %{
+ "token_type" => "bearer",
+ "access_token" => "VerySecretNew",
+ "expires_in" => 172_800
+ })
+
+ %{request_path: "/v2/customers/500"} = conn ->
+ case Plug.Conn.get_req_header(conn, "authorization") do
+ ["Bearer VerySecretExpired"] ->
+ conn
+ |> Plug.Conn.put_status(401)
+ |> Req.Test.text("Token expired")
+
+ ["Bearer VerySecretNew"] ->
+ Req.Test.json(conn, %{
+ "id" => 500,
+ "_embedded" => %{
+ "emails" => [
+ %{
+ "id" => 1,
+ "value" => email,
+ "type" => "home"
+ }
+ ]
+ }
+ })
+ end
+ end)
+
+ assert {:ok, _} = HelpScout.get_customer_details("500")
+ end
+ end
+
+ defp stub_help_scout_requests(email) do
+ Req.Test.stub(HelpScout, fn
+ %{request_path: "/v2/oauth2/token"} = conn ->
+ Req.Test.json(conn, %{
+ "token_type" => "bearer",
+ "access_token" => "369dbb08be58430086d2f8bd832bc1eb",
+ "expires_in" => 172_800
+ })
+
+ %{request_path: "/v2/customers/500"} = conn ->
+ Req.Test.json(conn, %{
+ "id" => 500,
+ "_embedded" => %{
+ "emails" => [
+ %{
+ "id" => 1,
+ "value" => email,
+ "type" => "home"
+ }
+ ]
+ }
+ })
+ end)
+ end
+ end
+end
diff --git a/test/plausible_web/controllers/help_scout_controller_test.exs b/test/plausible_web/controllers/help_scout_controller_test.exs
new file mode 100644
index 000000000..7824d3a3f
--- /dev/null
+++ b/test/plausible_web/controllers/help_scout_controller_test.exs
@@ -0,0 +1,57 @@
+defmodule PlausibleWeb.HelpScoutControllerTest do
+ use PlausibleWeb.ConnCase, async: true
+ use Plausible
+
+ @moduletag :ee_only
+
+ on_ee do
+ alias Plausible.HelpScout
+
+ describe "callback/2" do
+ test "returns details on success", %{conn: conn} do
+ user = insert(:user)
+ signature_key = Application.fetch_env!(:plausible, HelpScout)[:signature_key]
+ data = ~s|{"customer-id":"500"}|
+
+ signature =
+ :hmac
+ |> :crypto.mac(:sha, signature_key, data)
+ |> Base.encode64()
+ |> URI.encode_www_form()
+
+ Req.Test.stub(HelpScout, fn
+ %{request_path: "/v2/oauth2/token"} = conn ->
+ Req.Test.json(conn, %{
+ "token_type" => "bearer",
+ "access_token" => "369dbb08be58430086d2f8bd832bc1eb",
+ "expires_in" => 172_800
+ })
+
+ %{request_path: "/v2/customers/500"} = conn ->
+ Req.Test.json(conn, %{
+ "id" => 500,
+ "_embedded" => %{
+ "emails" => [
+ %{
+ "id" => 1,
+ "value" => user.email,
+ "type" => "home"
+ }
+ ]
+ }
+ })
+ end)
+
+ conn = get(conn, "/helpscout/callback?customer-id=500&X-HelpScout-Signature=#{signature}")
+
+ assert html_response(conn, 200) =~ "/crm/auth/user/#{user.id}"
+ end
+
+ test "returns error on failure", %{conn: conn} do
+ conn = get(conn, "/helpscout/callback?customer-id=500&X-HelpScout-Signature=invalid")
+
+ assert html_response(conn, 200) =~ "bad_signature"
+ end
+ end
+ end
+end