diff --git a/apps/core/lib/core/schema/account.ex b/apps/core/lib/core/schema/account.ex index 678f11539..e05886f07 100644 --- a/apps/core/lib/core/schema/account.ex +++ b/apps/core/lib/core/schema/account.ex @@ -10,6 +10,7 @@ defmodule Core.Schema.Account do field :icon, Core.Storage.Type field :billing_customer_id, :string field :delinquent_at, :utc_datetime_usec + field :grandfathered_until, :utc_datetime_usec field :user_count, :integer, default: 0 field :cluster_count, :integer, default: 0 field :usage_updated, :boolean diff --git a/apps/core/lib/core/services/payments.ex b/apps/core/lib/core/services/payments.ex index 9cfd6d2cc..f0a203e99 100644 --- a/apps/core/lib/core/services/payments.ex +++ b/apps/core/lib/core/services/payments.ex @@ -107,14 +107,36 @@ defmodule Core.Services.Payments do def preload(%User{} = user), do: Core.Repo.preload(user, @preloads) + @doc """ + determine if an account (or a user's account) is currently delinquent + """ + @spec delinquent?(User.t | Account.t) :: boolean + def delinquent?(%Account{delinquent_at: at}) when not is_nil(at) do + Timex.shift(at, days: 14) + |> Timex.before?(Timex.now()) + end + def delinquent?(%User{account: account}), do: delinquent?(account) + def delinquent?(_), do: false + + @doc """ + determine if an account (or a user's account) should be grandfathered into old features + """ + @spec grandfathered?(User.t | Account.t) :: boolean + def grandfathered?(%User{account: account}), do: grandfathered?(account) + def grandfathered?(%Account{grandfathered_until: at}) when not is_nil(at), do: Timex.after?(at, Timex.now()) + def grandfathered?(_), do: false + @doc """ Determine's if a user's account has access to the given feature. Returns `true` if enforcement is not enabled yet. """ @spec has_feature?(User.t, atom) :: boolean def has_feature?(%User{} = user, feature) do - case {enforce?(), preload(user)} do - {false, _} -> true - {_, %User{account: %Account{subscription: %PlatformSubscription{plan: %PlatformPlan{features: %{^feature => true}}}}}} -> true + user = preload(user) + case {enforce?(), delinquent?(user), grandfathered?(user), user} do + {false, _, _, _} -> true + {_, true, _, _} -> false + {_, _, true, _} -> true + {_, _, _, %User{account: %Account{subscription: %PlatformSubscription{plan: %PlatformPlan{features: %{^feature => true}}}}}} -> true _ -> false end end diff --git a/apps/core/lib/core/services/rollouts/rollable/versions.ex b/apps/core/lib/core/services/rollouts/rollable/versions.ex index 9214f7f78..08467b8d7 100644 --- a/apps/core/lib/core/services/rollouts/rollable/versions.ex +++ b/apps/core/lib/core/services/rollouts/rollable/versions.ex @@ -1,7 +1,7 @@ defimpl Core.Rollouts.Rollable, for: [Core.PubSub.VersionCreated, Core.PubSub.VersionUpdated] do use Core.Rollable.Base import Core.Rollable.Utils - alias Core.Services.{Dependencies, Upgrades, Rollouts} + alias Core.Services.{Dependencies, Upgrades, Rollouts, Payments} alias Core.Schema.{ChartInstallation, TerraformInstallation} def name(%Core.PubSub.VersionCreated{}), do: "version:created" @@ -13,7 +13,7 @@ defimpl Core.Rollouts.Rollable, for: [Core.PubSub.VersionCreated, Core.PubSub.Ve ChartInstallation.for_chart(chart_id) |> ChartInstallation.with_auto_upgrade(version.tags) |> maybe_ignore_version(@for, ChartInstallation, version.id) - |> ChartInstallation.preload(installation: [:repository, :user]) + |> ChartInstallation.preload(installation: [:repository, user: :account]) |> ChartInstallation.ordered() end @@ -21,7 +21,7 @@ defimpl Core.Rollouts.Rollable, for: [Core.PubSub.VersionCreated, Core.PubSub.Ve TerraformInstallation.for_terraform(tf_id) |> TerraformInstallation.with_auto_upgrade(version.tags) |> maybe_ignore_version(@for, TerraformInstallation, version.id) - |> TerraformInstallation.preload(installation: [:repository, :user]) + |> TerraformInstallation.preload(installation: [:repository, user: :account]) |> TerraformInstallation.ordered() end @@ -29,8 +29,8 @@ defimpl Core.Rollouts.Rollable, for: [Core.PubSub.VersionCreated, Core.PubSub.Ve defp maybe_ignore_version(q, _, mod, id), do: mod.ignore_version(q, id) def process(%{item: version}, %{installation: %{user: user}} = inst) do - case Dependencies.valid?(version.dependencies, user) do - true -> directly_install(version, inst) + case {Dependencies.valid?(version.dependencies, user), Payments.delinquent?(user)} do + {true, false} -> directly_install(version, inst) _ -> Upgrades.create_deferred_update(version.id, inst, user) end end diff --git a/apps/core/priv/repo/migrations/20230105230510_add_grandfathered_until.exs b/apps/core/priv/repo/migrations/20230105230510_add_grandfathered_until.exs new file mode 100644 index 000000000..3a63759a2 --- /dev/null +++ b/apps/core/priv/repo/migrations/20230105230510_add_grandfathered_until.exs @@ -0,0 +1,9 @@ +defmodule Core.Repo.Migrations.AddGrandfatheredUntil do + use Ecto.Migration + + def change do + alter table(:accounts) do + add :grandfathered_until, :utc_datetime_usec + end + end +end diff --git a/apps/core/test/services/payments_test.exs b/apps/core/test/services/payments_test.exs index e48edcddb..667a610f6 100644 --- a/apps/core/test/services/payments_test.exs +++ b/apps/core/test/services/payments_test.exs @@ -538,6 +538,52 @@ defmodule Core.Services.PaymentsTest do end end + describe "#has_feature?/2" do + test "if a user's plan has a feature, then it returns true" do + account = insert(:account) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: true})) + user = insert(:user, account: account) + assert Payments.has_feature?(user, :user_management) + end + + test "if a user's account is grandfathered, then it returns true" do + account = insert(:account, grandfathered_until: Timex.now() |> Timex.shift(days: 1)) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: false})) + user = insert(:user, account: account) + assert Payments.has_feature?(user, :user_management) + + account = insert(:account, grandfathered_until: Timex.now() |> Timex.shift(days: -1)) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: false})) + user = insert(:user, account: account) + refute Payments.has_feature?(user, :user_management) + end + + test "if a user's account is delinquent then it returns false" do + account = insert(:account, delinquent_at: Timex.now() |> Timex.shift(days: -100)) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: true})) + user = insert(:user, account: account) + refute Payments.has_feature?(user, :user_management) + + account = insert(:account, delinquent_at: Timex.now()) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: true})) + user = insert(:user, account: account) + assert Payments.has_feature?(user, :user_management) + end + + test "if a user's account has no plan it returns false" do + account = insert(:account) + user = insert(:user, account: account) + refute Payments.has_feature?(user, :user_management) + end + + test "if a user's account is doesn't have the feature, then it returns false" do + account = insert(:account) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: false})) + user = insert(:user, account: account) + refute Payments.has_feature?(user, :user_management) + end + end + describe "#update_plan/3" do test "Users can change plans" do expect(Stripe.Subscription, :update, fn diff --git a/apps/core/test/services/rollable/versions_test.exs b/apps/core/test/services/rollable/versions_test.exs index e33ff4ccd..9ddfb075d 100644 --- a/apps/core/test/services/rollable/versions_test.exs +++ b/apps/core/test/services/rollable/versions_test.exs @@ -83,6 +83,32 @@ defmodule Core.Rollable.VersionsTest do end end + test "it will defer updates if a user is delinquent" do + user = insert(:user, account: build(:account, delinquent_at: Timex.now() |> Timex.shift(days: -100))) + %{chart: chart} = chart_version = insert(:version, version: "0.1.0") + inst = insert(:chart_installation, + installation: insert(:installation, auto_upgrade: true, user: user), + chart: chart, + version: chart_version + ) + + version = insert(:version, version: "0.1.1", chart: chart) + insert(:version_tag, version: version, chart: chart, tag: "latest") + + event = %PubSub.VersionCreated{item: version} + {:ok, rollout} = Rollouts.create_rollout(chart.repository_id, event) + + {:ok, rolled} = Rollouts.execute(rollout) + + assert rolled.status == :finished + assert rolled.count == 1 + + [deferred] = Core.Repo.all(Core.Schema.DeferredUpdate) + + assert deferred.chart_installation_id == inst.id + assert deferred.version_id == version.id + end + test "it will defer updates if a version's dependencies aren't satisfied" do dep_chart = insert(:chart) %{chart: chart} = chart_version = insert(:version, version: "0.1.0") diff --git a/apps/core/test/support/test_helpers.ex b/apps/core/test/support/test_helpers.ex index 0dd20369c..fd48ab4e0 100644 --- a/apps/core/test/support/test_helpers.ex +++ b/apps/core/test/support/test_helpers.ex @@ -27,5 +27,10 @@ defmodule Core.TestHelpers do def refetch(%{__struct__: schema, id: id}), do: Core.Repo.get(schema, id) + def update_record(struct, attrs) do + Ecto.Changeset.change(struct, attrs) + |> Core.Repo.update() + end + def priv_file(app, path), do: Path.join(:code.priv_dir(app), path) end diff --git a/apps/graphql/lib/graphql/schema/account.ex b/apps/graphql/lib/graphql/schema/account.ex index fc6e1b47d..1bc4dc987 100644 --- a/apps/graphql/lib/graphql/schema/account.ex +++ b/apps/graphql/lib/graphql/schema/account.ex @@ -82,6 +82,7 @@ defmodule GraphQl.Schema.Account do field :billing_customer_id, :string field :workos_connection_id, :string field :delinquent_at, :datetime + field :grandfathered_unitl, :datetime field :icon, :string, resolve: fn account, _, _ -> {:ok, Core.Storage.url({account.icon, account}, :original)} diff --git a/apps/graphql/test/mutations/account_mutations_test.exs b/apps/graphql/test/mutations/account_mutations_test.exs index dadf4086a..3d578e55e 100644 --- a/apps/graphql/test/mutations/account_mutations_test.exs +++ b/apps/graphql/test/mutations/account_mutations_test.exs @@ -32,6 +32,30 @@ defmodule GraphQl.AccountMutationTest do assert hd(svc["impersonationPolicy"]["bindings"])["group"]["id"] == group.id end + test "delinquent accounts cannot create service accounts", %{user: user, account: account} do + {:ok, account} = update_record(account, %{delinquent_at: Timex.now() |> Timex.shift(days: -100)}) + insert(:platform_subscription, account: account, plan: build(:platform_plan, features: %{user_management: true})) + group = insert(:group, account: user.account) + {:ok, %{errors: [_ | _]}} = run_query(""" + mutation createSvcAccount($attributes: ServiceAccountAttributes!) { + createServiceAccount(attributes: $attributes) { + id + serviceAccount + impersonationPolicy { + bindings { group { id } } + } + } + } + """, + %{ + "attributes" => %{ + "name" => "svc", + "impersonationPolicy" => %{"bindings" => [%{"groupId" => group.id}]} + } + }, + %{current_user: Core.Services.Rbac.preload(refetch(user))}) + end + test "it will fail to create service accounts if feature doesn't exist", %{user: user} do group = insert(:group, account: user.account) {:ok, %{errors: [_ | _]}} = run_query("""