diff --git a/config/.env.test b/config/.env.test
index 42a1e8552634..953a1d24069e 100644
--- a/config/.env.test
+++ b/config/.env.test
@@ -19,6 +19,7 @@ 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=
+PLAIN_CUSTOMER_CARD_TOKEN=fake_plain_token
S3_DISABLED=false
S3_ACCESS_KEY_ID=minioadmin
diff --git a/config/runtime.exs b/config/runtime.exs
index 0ecd2d04d558..946fa53cdd93 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -887,6 +887,14 @@ config :plausible, :hcaptcha,
nolt_sso_secret = get_var_from_path_or_env(config_dir, "NOLT_SSO_SECRET")
config :joken, default_signer: nolt_sso_secret
+# Plain customer support integration.
+# hmac_secret - used for signing data in the chat widget
+# token - used for authenticating customer card requests (Bearer token in Authorization header)
+config :plausible, :plain,
+ app_id: get_var_from_path_or_env(config_dir, "PLAIN_APP_ID"),
+ hmac_secret: get_var_from_path_or_env(config_dir, "PLAIN_HMAC_SECRET"),
+ customer_card_token: get_var_from_path_or_env(config_dir, "PLAIN_CUSTOMER_CARD_TOKEN")
+
config :plausible, Plausible.Sentry.Client,
finch_request_opts: [
pool_timeout: get_int_from_path_or_env(config_dir, "SENTRY_FINCH_POOL_TIMEOUT", 5000),
diff --git a/extra/lib/plausible/plain_customer_cards.ex b/extra/lib/plausible/plain_customer_cards.ex
new file mode 100644
index 000000000000..a882bc01ba8d
--- /dev/null
+++ b/extra/lib/plausible/plain_customer_cards.ex
@@ -0,0 +1,402 @@
+defmodule Plausible.PlainCustomerCards do
+ @moduledoc """
+ Plain customer cards API logic.
+
+ Plain sends a POST request with the customer's email and we respond
+ with structured card components showing subscription status, plan, etc.
+ """
+
+ import Ecto.Query
+
+ alias Plausible.Billing
+ alias Plausible.Billing.Subscription
+ alias Plausible.Repo
+ alias Plausible.Teams
+
+ alias PlausibleWeb.Router.Helpers, as: Routes
+
+ require Plausible.Billing.Subscription.Status
+
+ @spec build_cards(String.t() | nil, [String.t()]) :: [map()]
+ def build_cards(email, card_keys) do
+ case get_customer_data(email || "") do
+ {:ok, details} ->
+ card = build_card(details)
+ Enum.map(card_keys, fn _key -> card end)
+
+ {:error, _} ->
+ Enum.map(card_keys, fn key ->
+ %{
+ key: key,
+ timeToLiveSeconds: 60,
+ components: [
+ %{componentText: %{text: "Customer not found", textSize: "M", textColor: "MUTED"}}
+ ]
+ }
+ end)
+ end
+ end
+
+ @spec get_customer_data(String.t()) :: {:ok, map()} | {:error, any()}
+ defp get_customer_data(email) do
+ user =
+ users_query()
+ |> where([user: u], u.email == ^email)
+ |> Repo.one()
+
+ if user do
+ teams = Teams.Users.owned_teams(user)
+
+ if length(teams) > 1 do
+ multiple_teams_details(user, teams)
+ else
+ single_team_details(user, List.first(teams))
+ end
+ else
+ {:error, {:user_not_found, email}}
+ end
+ end
+
+ defp multiple_teams_details(user, teams) do
+ teams =
+ Enum.map(teams, fn team ->
+ %{
+ name: Teams.name(team),
+ identifier: team.identifier,
+ sites_count: Teams.owned_sites_count(team)
+ }
+ end)
+
+ user_link = Routes.customer_support_user_url(PlausibleWeb.Endpoint, :show, user.id)
+
+ {:ok,
+ %{
+ multiple_teams?: true,
+ email: user.email,
+ notes: notes(user, nil),
+ teams: teams,
+ user_link: user_link
+ }}
+ end
+
+ defp single_team_details(user, nil) do
+ status_link =
+ Routes.customer_support_user_url(PlausibleWeb.Endpoint, :show, user.id)
+
+ {:ok,
+ %{
+ multiple_teams?: false,
+ team_setup?: false,
+ team_name: nil,
+ email: user.email,
+ notes: notes(user, nil),
+ status_label: status_label(nil, nil),
+ status_link: status_link,
+ plan_label: plan_label(nil, nil),
+ plan_link: plan_link(nil),
+ sites_count: 0
+ }}
+ end
+
+ defp single_team_details(user, team) do
+ team = Teams.with_subscription(team)
+ plan = Billing.Plans.get_subscription_plan(team.subscription)
+
+ status_link =
+ Routes.customer_support_team_url(PlausibleWeb.Endpoint, :show, team.id)
+
+ {:ok,
+ %{
+ multiple_teams?: false,
+ team_setup?: Plausible.Teams.setup?(team),
+ team_name: Plausible.Teams.name(team),
+ email: user.email,
+ notes: notes(user, team),
+ status_label: status_label(team, team.subscription),
+ status_link: status_link,
+ plan_label: plan_label(team.subscription, plan),
+ plan_link: plan_link(team.subscription),
+ sites_count: Teams.owned_sites_count(team)
+ }}
+ end
+
+ @spec build_card(map()) :: map()
+ defp build_card(%{multiple_teams?: true} = details) do
+ %{
+ key: "customer-details",
+ timeToLiveSeconds: 60,
+ components:
+ [
+ %{
+ componentText: %{
+ text: "Multiple teams (#{length(details.teams)})",
+ textSize: "L",
+ textColor: "NORMAL"
+ }
+ }
+ ] ++
+ Enum.map(details.teams, fn team ->
+ %{
+ componentRow: %{
+ rowMainContent: [
+ %{componentText: %{text: team.name, textSize: "M", textColor: "NORMAL"}}
+ ],
+ rowAsideContent: [
+ %{
+ componentText: %{
+ text: "#{team.sites_count} sites",
+ textSize: "S",
+ textColor: "MUTED"
+ }
+ }
+ ]
+ }
+ }
+ end) ++
+ [
+ %{componentDivider: %{dividerSpacingSize: "M"}},
+ %{
+ componentLinkButton: %{
+ linkButtonLabel: "View user in CRM",
+ linkButtonUrl: details.user_link
+ }
+ }
+ ]
+ }
+ end
+
+ defp build_card(%{multiple_teams?: false} = details) do
+ components =
+ [
+ %{
+ componentRow: %{
+ rowMainContent: [
+ %{componentText: %{text: "Status", textSize: "S", textColor: "MUTED"}}
+ ],
+ rowAsideContent: [
+ %{
+ componentBadge: %{
+ badgeLabel: details.status_label,
+ badgeColor: status_badge_color(details.status_label)
+ }
+ }
+ ]
+ }
+ },
+ %{
+ componentRow: %{
+ rowMainContent: [
+ %{componentText: %{text: "Plan", textSize: "S", textColor: "MUTED"}}
+ ],
+ rowAsideContent: [
+ %{componentText: %{text: details.plan_label, textSize: "S", textColor: "NORMAL"}}
+ ]
+ }
+ },
+ %{
+ componentRow: %{
+ rowMainContent: [
+ %{componentText: %{text: "Sites", textSize: "S", textColor: "MUTED"}}
+ ],
+ rowAsideContent: [
+ %{
+ componentText: %{
+ text: to_string(details.sites_count),
+ textSize: "S",
+ textColor: "NORMAL"
+ }
+ }
+ ]
+ }
+ }
+ ] ++
+ if details.team_setup? do
+ [
+ %{
+ componentRow: %{
+ rowMainContent: [
+ %{componentText: %{text: "Team", textSize: "S", textColor: "MUTED"}}
+ ],
+ rowAsideContent: [
+ %{
+ componentText: %{
+ text: details.team_name,
+ textSize: "S",
+ textColor: "NORMAL"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ else
+ []
+ end
+
+ components =
+ if details.notes do
+ components ++
+ [
+ %{componentText: %{text: details.notes, textSize: "S", textColor: "MUTED"}}
+ ]
+ else
+ components
+ end
+
+ components =
+ components ++
+ [
+ %{componentDivider: %{dividerSpacingSize: "M"}},
+ %{
+ componentLinkButton: %{
+ linkButtonLabel: "View in CRM",
+ linkButtonUrl: details.status_link
+ }
+ }
+ ]
+
+ components =
+ if details.plan_link != "#" do
+ components ++
+ [
+ %{
+ componentLinkButton: %{
+ linkButtonLabel: "Manage in Paddle",
+ linkButtonUrl: details.plan_link
+ }
+ }
+ ]
+ else
+ components
+ end
+
+ %{
+ key: "customer-details",
+ timeToLiveSeconds: 60,
+ components: components
+ }
+ end
+
+ defp status_badge_color(status_label) do
+ case status_label do
+ "Paid" -> "GREEN"
+ "Trial" -> "GREEN"
+ "Pending cancellation" -> "YELLOW"
+ "Paused" -> "YELLOW"
+ "Expired trial" -> "RED"
+ "Canceled" -> "RED"
+ _ -> "GREY"
+ 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(team, subscription) do
+ subscription_active? = Billing.Subscriptions.active?(subscription)
+ trial? = Teams.on_trial?(team)
+
+ cond do
+ not subscription_active? and not trial? and (is_nil(team) or is_nil(team.trial_expiry_date)) ->
+ "None"
+
+ is_nil(subscription) and not trial? ->
+ "Expired trial"
+
+ trial? ->
+ "Trial"
+
+ subscription.status == Subscription.Status.deleted() ->
+ if subscription_active? do
+ "Pending cancellation"
+ else
+ "Canceled"
+ end
+
+ subscription.status == Subscription.Status.paused() ->
+ "Paused"
+
+ Teams.locked?(team) ->
+ "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 users_query() do
+ from(u in Plausible.Auth.User,
+ as: :user,
+ left_join: tm in assoc(u, :team_memberships),
+ on: tm.role == :owner,
+ as: :team_memberships,
+ left_join: t in assoc(tm, :team),
+ left_join: s in assoc(t, :sites),
+ as: :sites,
+ where: is_nil(s) or not s.consolidated,
+ group_by: u.id,
+ order_by: [desc: count(s.id)]
+ )
+ end
+
+ defp notes(user, team) do
+ notes =
+ [
+ user.notes,
+ team && team.notes
+ ]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join("\n")
+
+ if notes != "", do: notes
+ end
+end
diff --git a/extra/lib/plausible_web/controllers/plain_controller.ex b/extra/lib/plausible_web/controllers/plain_controller.ex
new file mode 100644
index 000000000000..f8d06e63070b
--- /dev/null
+++ b/extra/lib/plausible_web/controllers/plain_controller.ex
@@ -0,0 +1,18 @@
+defmodule PlausibleWeb.PlainController do
+ use PlausibleWeb, :controller
+
+ alias Plausible.PlainCustomerCards
+
+ def customer_cards(conn, params) do
+ token = Application.get_env(:plausible, :plain)[:customer_card_token]
+ auth = get_req_header(conn, "authorization") |> List.first()
+
+ if Plug.Crypto.secure_compare(auth || "", "Bearer #{token}") do
+ email = get_in(params, ["customer", "email"])
+ card_keys = Map.get(params, "cardKeys", ["customer-details"])
+ json(conn, %{cards: PlainCustomerCards.build_cards(email, card_keys)})
+ else
+ conn |> put_status(401) |> json(%{error: "Unauthorized"})
+ end
+ end
+end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index 157e4bb41498..435e5c6cc979 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -540,6 +540,12 @@ defmodule PlausibleWeb.Router do
get "/helpscout/show", HelpScoutController, :show
get "/helpscout/search", HelpScoutController, :search
end
+
+ scope "/", PlausibleWeb do
+ pipe_through [:external_api]
+
+ post "/plain/customer-cards", PlainController, :customer_cards
+ end
end
scope "/", PlausibleWeb do
diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex
index dc217ba23fa9..12606439c8f4 100644
--- a/lib/plausible_web/templates/layout/app.html.heex
+++ b/lib/plausible_web/templates/layout/app.html.heex
@@ -59,5 +59,28 @@
<% end %>
+ <%= case plain_chat_config(assigns[:current_user]) do %>
+ <% %{app_id: app_id, email: email, email_hash: email_hash, full_name: full_name} -> %>
+
+ <% nil -> %>
+ <% end %>