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:
Vinicius Brasil 2023-11-21 11:25:54 -03:00 committed by GitHub
parent 65cc8980e0
commit b35096bbc8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 125 additions and 98 deletions

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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