Skip to content

Commit

Permalink
Implement delinquency enforcement (#848)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaeljguarino authored Jan 6, 2023
1 parent b8e5642 commit e2685a9
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 8 deletions.
1 change: 1 addition & 0 deletions apps/core/lib/core/schema/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions apps/core/lib/core/services/payments.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions apps/core/lib/core/services/rollouts/rollable/versions.ex
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,24 +13,24 @@ 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

def query(%{item: %{terraform_id: tf_id} = version}) when is_binary(tf_id) do
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

# defp maybe_ignore_version(q, Core.PubSub.VersionUpdated, _, _), do: q
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions apps/core/test/services/payments_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions apps/core/test/services/rollable/versions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions apps/core/test/support/test_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions apps/graphql/lib/graphql/schema/account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
24 changes: 24 additions & 0 deletions apps/graphql/test/mutations/account_mutations_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down

0 comments on commit e2685a9

Please sign in to comment.