mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
Dump plan information to PostgreSQL (#3543)
* Use Ecto.Schema for casting plans from JSON files * Dump plans to internal database table
This commit is contained in:
parent
65cc8980e0
commit
b35096bbc8
43
lib/mix/tasks/dump_plans.ex
Normal file
43
lib/mix/tasks/dump_plans.ex
Normal file
@ -0,0 +1,43 @@
|
||||
defmodule Mix.Tasks.DumpPlans do
|
||||
@moduledoc """
|
||||
This task dumps plan information from the JSON files to the `plans` table in
|
||||
PostgreSQL for internal use. This task deletes existing records and
|
||||
(re)inserts the list of plans.
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
require Logger
|
||||
|
||||
@table "plans"
|
||||
|
||||
def run(_args) do
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
Plausible.Repo.delete_all(@table)
|
||||
|
||||
plans =
|
||||
Plausible.Billing.Plans.all()
|
||||
|> Plausible.Billing.Plans.with_prices()
|
||||
|> Enum.map(&Map.from_struct/1)
|
||||
|> Enum.map(&prepare_for_dump/1)
|
||||
|
||||
{count, _} = Plausible.Repo.insert_all(@table, plans)
|
||||
|
||||
Logger.info("Inserted #{count} plans")
|
||||
end
|
||||
|
||||
defp prepare_for_dump(plan) do
|
||||
monthly_cost = plan.monthly_cost && Money.to_decimal(plan.monthly_cost)
|
||||
yearly_cost = plan.yearly_cost && Money.to_decimal(plan.yearly_cost)
|
||||
{:ok, features} = Plausible.Billing.Ecto.FeatureList.dump(plan.features)
|
||||
{:ok, team_member_limit} = Plausible.Billing.Ecto.Limit.dump(plan.team_member_limit)
|
||||
|
||||
plan
|
||||
|> Map.drop([:id])
|
||||
|> Map.put(:kind, Atom.to_string(plan.kind))
|
||||
|> Map.put(:monthly_cost, monthly_cost)
|
||||
|> Map.put(:yearly_cost, yearly_cost)
|
||||
|> Map.put(:features, features)
|
||||
|> Map.put(:team_member_limit, team_member_limit)
|
||||
end
|
||||
end
|
@ -11,6 +11,7 @@ defmodule Plausible.Billing.Ecto.Limit do
|
||||
|
||||
def cast(-1), do: {:ok, :unlimited}
|
||||
def cast(:unlimited), do: {:ok, :unlimited}
|
||||
def cast("unlimited"), do: {:ok, :unlimited}
|
||||
def cast(other), do: Ecto.Type.cast(:integer, other)
|
||||
|
||||
def load(-1), do: {:ok, :unlimited}
|
||||
|
@ -1,95 +1,59 @@
|
||||
defmodule Plausible.Billing.Plan do
|
||||
@moduledoc false
|
||||
|
||||
@derive Jason.Encoder
|
||||
@enforce_keys ~w(kind generation site_limit monthly_pageview_limit team_member_limit features volume monthly_product_id yearly_product_id)a
|
||||
defstruct @enforce_keys ++ [:monthly_cost, :yearly_cost]
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t() ::
|
||||
%__MODULE__{
|
||||
kind: atom(),
|
||||
generation: non_neg_integer(),
|
||||
monthly_pageview_limit: non_neg_integer(),
|
||||
site_limit: non_neg_integer(),
|
||||
team_member_limit: non_neg_integer() | :unlimited,
|
||||
volume: String.t(),
|
||||
monthly_cost: Money.t() | nil,
|
||||
yearly_cost: Money.t() | nil,
|
||||
monthly_product_id: String.t() | nil,
|
||||
yearly_product_id: String.t() | nil,
|
||||
features: [atom()]
|
||||
}
|
||||
| :enterprise
|
||||
@type t() :: %__MODULE__{} | :enterprise
|
||||
|
||||
def build!(raw_params, file_name) when is_map(raw_params) do
|
||||
raw_params
|
||||
|> put_kind()
|
||||
|> put_generation()
|
||||
|> put_volume()
|
||||
|> put_team_member_limit!(file_name)
|
||||
|> put_features!(file_name)
|
||||
|> new!()
|
||||
end
|
||||
|
||||
defp new!(params) do
|
||||
struct!(__MODULE__, params)
|
||||
end
|
||||
|
||||
defp put_kind(params) do
|
||||
Map.put(params, :kind, String.to_existing_atom(params.kind))
|
||||
end
|
||||
|
||||
# Due to grandfathering, we sometimes need to check the "generation"
|
||||
# (e.g. v1, v2, etc...) of a user's subscription plan. For instance,
|
||||
# on prod, the users subscribed to a v2 plan are only supposed to
|
||||
# see v2 plans when they go to the upgrade page.
|
||||
embedded_schema do
|
||||
# Due to grandfathering, we sometimes need to check the "generation" (e.g.
|
||||
# v1, v2, etc...) of a user's subscription plan. For instance, on prod, the
|
||||
# users subscribed to a v2 plan are only supposed to see v2 plans when they
|
||||
# go to the upgrade page.
|
||||
#
|
||||
# In the `dev` environment though, "sandbox" plans are used, which
|
||||
# unlike production plans, contain multiple generations of plans in
|
||||
# the same file for testing purposes.
|
||||
defp put_generation(params) do
|
||||
Map.put(params, :generation, params.generation)
|
||||
# In the `dev` environment though, "sandbox" plans are used, which unlike
|
||||
# production plans, contain multiple generations of plans in the same file
|
||||
# for testing purposes.
|
||||
field :generation, :integer
|
||||
field :kind, Ecto.Enum, values: [:growth, :business]
|
||||
|
||||
field :features, Plausible.Billing.Ecto.FeatureList
|
||||
field :monthly_pageview_limit, :integer
|
||||
field :site_limit, :integer
|
||||
field :team_member_limit, Plausible.Billing.Ecto.Limit
|
||||
field :volume, :string
|
||||
|
||||
field :monthly_cost
|
||||
field :monthly_product_id, :string
|
||||
field :yearly_cost
|
||||
field :yearly_product_id, :string
|
||||
end
|
||||
|
||||
defp put_volume(params) do
|
||||
volume =
|
||||
params.monthly_pageview_limit
|
||||
|> PlausibleWeb.StatsView.large_number_format()
|
||||
@fields ~w(generation kind features monthly_pageview_limit site_limit team_member_limit volume monthly_cost monthly_product_id yearly_cost yearly_product_id)a
|
||||
|
||||
Map.put(params, :volume, volume)
|
||||
end
|
||||
|
||||
defp put_team_member_limit!(params, file_name) do
|
||||
team_member_limit =
|
||||
case params.team_member_limit do
|
||||
number when is_integer(number) ->
|
||||
number
|
||||
|
||||
"unlimited" ->
|
||||
:unlimited
|
||||
|
||||
other ->
|
||||
raise ArgumentError,
|
||||
"Failed to parse team member limit #{inspect(other)} from #{file_name}.json"
|
||||
end
|
||||
|
||||
Map.put(params, :team_member_limit, team_member_limit)
|
||||
end
|
||||
|
||||
defp put_features!(params, file_name) do
|
||||
features =
|
||||
Plausible.Billing.Feature.list()
|
||||
|> Enum.filter(fn module ->
|
||||
to_string(module.name()) in params.features
|
||||
end)
|
||||
|
||||
if length(features) == length(params.features) do
|
||||
Map.put(params, :features, features)
|
||||
else
|
||||
raise(
|
||||
ArgumentError,
|
||||
"Unrecognized feature(s) in #{inspect(params.features)} (#{file_name}.json)"
|
||||
def changeset(plan, attrs) do
|
||||
plan
|
||||
|> cast(attrs, @fields)
|
||||
|> put_volume()
|
||||
|> validate_required_either([:monthly_product_id, :yearly_product_id])
|
||||
|> validate_required(
|
||||
@fields -- [:monthly_cost, :yearly_cost, :monthly_product_id, :yearly_product_id]
|
||||
)
|
||||
end
|
||||
|
||||
defp put_volume(changeset) do
|
||||
if volume = get_field(changeset, :monthly_pageview_limit) do
|
||||
put_change(changeset, :volume, PlausibleWeb.StatsView.large_number_format(volume))
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
def validate_required_either(changeset, fields) do
|
||||
if Enum.any?(fields, &get_field(changeset, &1)),
|
||||
do: changeset,
|
||||
else:
|
||||
add_error(changeset, hd(fields), "one of these fields must be present #{inspect(fields)}")
|
||||
end
|
||||
end
|
||||
|
@ -16,10 +16,9 @@ defmodule Plausible.Billing.Plans do
|
||||
path = Application.app_dir(:plausible, ["priv", "#{f}.json"])
|
||||
|
||||
plans_list =
|
||||
path
|
||||
|> File.read!()
|
||||
|> Jason.decode!(keys: :atoms!)
|
||||
|> Enum.map(&Plan.build!(&1, f))
|
||||
for attrs <- path |> File.read!() |> Jason.decode!() do
|
||||
%Plan{} |> Plan.changeset(attrs) |> Ecto.Changeset.apply_action!(nil)
|
||||
end
|
||||
|
||||
Module.put_attribute(__MODULE__, f, plans_list)
|
||||
|
||||
@ -261,7 +260,7 @@ defmodule Plausible.Billing.Plans do
|
||||
end
|
||||
end
|
||||
|
||||
defp all() do
|
||||
def all() do
|
||||
@legacy_plans ++ @plans_v1 ++ @plans_v2 ++ @plans_v3 ++ @plans_v4 ++ sandbox_plans()
|
||||
end
|
||||
|
||||
|
@ -245,8 +245,7 @@ defmodule Plausible.Billing.Quota do
|
||||
from g in Plausible.Goal, where: g.site_id == ^site.id and not is_nil(g.currency)
|
||||
)
|
||||
|
||||
used_features =
|
||||
[
|
||||
used_features = [
|
||||
{Props, props_exist},
|
||||
{Funnels, funnels_exist},
|
||||
{RevenueGoals, revenue_goals_exist}
|
||||
|
21
priv/repo/migrations/20231121131602_create_plans_table.exs
Normal file
21
priv/repo/migrations/20231121131602_create_plans_table.exs
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule Plausible.Repo.Migrations.CreatePlansTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
if !Application.get_env(:plausible, :is_selfhost) do
|
||||
create table(:plans) do
|
||||
add :generation, :integer, null: false
|
||||
add :kind, :string, null: false
|
||||
add :features, {:array, :string}, null: false
|
||||
add :monthly_pageview_limit, :integer, null: false
|
||||
add :site_limit, :integer, null: false
|
||||
add :team_member_limit, :integer, null: false
|
||||
add :volume, :string, null: false
|
||||
add :monthly_cost, :decimal, null: true
|
||||
add :monthly_product_id, :string, null: true
|
||||
add :yearly_cost, :decimal, null: true
|
||||
add :yearly_product_id, :string, null: true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -517,9 +517,9 @@ defmodule Plausible.Billing.QuotaTest do
|
||||
user_on_v2 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
|
||||
user_on_v3 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_plan_id))
|
||||
|
||||
assert [Goals, StatsAPI, Props] == Quota.allowed_features_for(user_on_v1)
|
||||
assert [Goals, StatsAPI, Props] == Quota.allowed_features_for(user_on_v2)
|
||||
assert [Goals, StatsAPI, Props] == Quota.allowed_features_for(user_on_v3)
|
||||
assert [Goals, Props, StatsAPI] == Quota.allowed_features_for(user_on_v1)
|
||||
assert [Goals, Props, StatsAPI] == Quota.allowed_features_for(user_on_v2)
|
||||
assert [Goals, Props, StatsAPI] == Quota.allowed_features_for(user_on_v3)
|
||||
end
|
||||
|
||||
test "returns [Goals, Props, StatsAPI] when user is on free_10k plan" do
|
||||
@ -560,7 +560,7 @@ defmodule Plausible.Billing.QuotaTest do
|
||||
subscription: build(:subscription, paddle_plan_id: @v1_plan_id)
|
||||
)
|
||||
|
||||
assert [Goals, StatsAPI, Props] == Quota.allowed_features_for(user)
|
||||
assert [Goals, Props, StatsAPI] == Quota.allowed_features_for(user)
|
||||
end
|
||||
|
||||
test "returns all features for enterprise users who have not upgraded yet and are on trial" do
|
||||
|
Loading…
Reference in New Issue
Block a user