diff --git a/lib/code_corps/donation_goals_manager.ex b/lib/code_corps/donation_goals_manager.ex new file mode 100644 index 000000000..be101a034 --- /dev/null +++ b/lib/code_corps/donation_goals_manager.ex @@ -0,0 +1,115 @@ +defmodule CodeCorps.DonationGoalsManager do + @moduledoc """ + Handles CRUD operations for donation goals. + + When operations happen on `CodeCorps.DonationGoal`, we need to set + the current donation goal on `CodeCorps.Project`. + + The current donation goal should be the smallest value that is + greater than the project's current total donations, _or_ falls back to + the largest donation goal. + """ + + import Ecto.Query + + alias CodeCorps.{DonationGoal, Project, Repo} + alias Ecto.Multi + + def create(attributes) do + changeset = %DonationGoal{} |> DonationGoal.create_changeset(attributes) + + multi = Multi.new + |> Multi.insert(:donation_goal, changeset) + |> Multi.run(:update_related_goals, &update_related_goals/1) + + case Repo.transaction(multi) do + {:ok, %{donation_goal: donation_goal, update_related_goals: _}} -> + {:ok, donation_goal} + {:error, :donation_goal, %Ecto.Changeset{} = changeset, %{}} -> + {:error, changeset} + {:error, _failed_operation, _failed_value, _changes_so_far} -> + {:error, :unhandled} + end + end + + def update(%DonationGoal{} = donation_goal, attributes) do + changeset = donation_goal |> DonationGoal.create_changeset(attributes) + + multi = Multi.new + |> Multi.update(:donation_goal, changeset) + |> Multi.run(:update_related_goals, &update_related_goals/1) + + case Repo.transaction(multi) do + {:ok, %{donation_goal: donation_goal, update_related_goals: _}} -> + {:ok, Repo.get(DonationGoal, donation_goal.id)} + {:error, :donation_goal, %Ecto.Changeset{} = changeset, %{}} -> + {:error, changeset} + {:error, _failed_operation, _failed_value, _changes_so_far} -> + {:error, :unhandled} + end + end + + def set_current_goal_for_project(%Project{} = project) do + project + |> find_current_goal + |> set_to_current(project) + end + + defp update_related_goals(%{donation_goal: %DonationGoal{project_id: project_id}}) do + Project + |> Repo.get(project_id) + |> set_current_goal_for_project + end + + defp find_current_goal(%Project{} = project) do + amount_donated = get_amount_donated(project) + case find_lowest_not_yet_reached(project, amount_donated) do + nil -> find_largest_goal(project) + %DonationGoal{} = donation_goal -> donation_goal + end + end + + defp get_amount_donated(%Project{id: project_id}) do + # TODO: This should be simplified by having + # subscriptions relate to projects instead of plans + # and by caching the total amount on the project itself + + CodeCorps.StripeConnectPlan + |> Repo.get_by(project_id: project_id) + |> aggregate_donations + |> default_to_zero + end + + defp aggregate_donations(nil), do: 0 + defp aggregate_donations(%CodeCorps.StripeConnectPlan{id: plan_id}) do + CodeCorps.StripeConnectSubscription + |> where([s], s.stripe_connect_plan_id == ^plan_id) + |> Repo.aggregate(:sum, :quantity) + end + + defp default_to_zero(nil), do: 0 + defp default_to_zero(value), do: value + + defp find_lowest_not_yet_reached(%Project{id: project_id}, amount_donated) do + DonationGoal + |> where([d], d.project_id == ^project_id and d.amount > ^amount_donated) + |> order_by(asc: :amount) + |> limit(1) + |> Repo.one + end + + defp find_largest_goal(%Project{id: project_id}) do + DonationGoal + |> where([d], d.project_id == ^project_id) + |> order_by(desc: :amount) + |> limit(1) + |> Repo.one + end + + defp set_to_current(%DonationGoal{} = donation_goal, %Project{} = project) do + attrs = %{current_donation_goal_id: donation_goal.id} + project + |> Project.set_current_donation_goal_changeset(attrs) + |> Repo.update + end +end diff --git a/priv/repo/migrations/20161126045705_add_current_donation_goal.exs b/priv/repo/migrations/20161126045705_add_current_donation_goal.exs new file mode 100644 index 000000000..6833a77c0 --- /dev/null +++ b/priv/repo/migrations/20161126045705_add_current_donation_goal.exs @@ -0,0 +1,27 @@ +defmodule CodeCorps.Repo.Migrations.AddCurrentDonationGoal do + use Ecto.Migration + + def up do + alter table(:projects) do + add :current_donation_goal_id, references(:donation_goals) + end + + create unique_index(:projects, [:current_donation_goal_id]) + + alter table(:donation_goals) do + remove(:current) + end + end + + def down do + drop_if_exists unique_index(:projects, [:current_donation_goal_id]) + + alter table(:projects) do + remove(:current_donation_goal_id) + end + + alter table(:donation_goals) do + add :current, :boolean, default: false + end + end +end diff --git a/test/controllers/donation_goal_controller_test.exs b/test/controllers/donation_goal_controller_test.exs index 5869032dc..7b55a7bf9 100644 --- a/test/controllers/donation_goal_controller_test.exs +++ b/test/controllers/donation_goal_controller_test.exs @@ -1,7 +1,7 @@ defmodule CodeCorps.DonationGoalControllerTest do use CodeCorps.ApiCase, resource_name: :donation_goal - @valid_attrs %{amount: 200, current: false, description: "A description"} + @valid_attrs %{amount: 200, description: "A description"} @invalid_attrs %{description: nil} describe "index" do @@ -67,7 +67,9 @@ defmodule CodeCorps.DonationGoalControllerTest do describe "update" do @tag authenticated: :admin test "updates and renders chosen resource when data is valid", %{conn: conn} do - assert conn |> request_update(@valid_attrs) |> json_response(200) + project = insert(:project) + attrs = @valid_attrs |> Map.merge(%{project: project}) + assert conn |> request_update(attrs) |> json_response(200) end @tag authenticated: :admin diff --git a/test/lib/code_corps/donation_goals_manager_test.exs b/test/lib/code_corps/donation_goals_manager_test.exs new file mode 100644 index 000000000..9fa3e47fa --- /dev/null +++ b/test/lib/code_corps/donation_goals_manager_test.exs @@ -0,0 +1,167 @@ +defmodule CodeCorps.DonationGoalsManagerTest do + use ExUnit.Case, async: true + + use CodeCorps.ModelCase + + alias CodeCorps.DonationGoal + alias CodeCorps.DonationGoalsManager + alias CodeCorps.Project + + defp assert_current_goal_id(goal_id) do + project = + Project + |> Repo.get_by(current_donation_goal_id: goal_id) + |> Repo.preload([:current_donation_goal]) + assert project.current_donation_goal.id == goal_id + end + + defp donate(plan, amount) do + insert(:stripe_connect_subscription, quantity: amount, stripe_connect_plan: plan) + end + + describe "create/1" do + test "inserts new goal, returns {:ok, record}" do + project = insert(:project) + insert(:stripe_connect_plan, project: project) + + {:ok, %DonationGoal{} = donation_goal} = DonationGoalsManager.create(%{amount: 10, description: "Test", project_id: project.id}) + assert_current_goal_id(donation_goal.id) + end + + test "returns {:error, changeset} if there are validation errors" do + {:error, %Ecto.Changeset{} = changeset} = DonationGoalsManager.create(%{amount: 10}) + refute changeset.valid? + end + + test "sets current goal correctly" do + project = insert(:project) + plan = insert(:stripe_connect_plan, project: project) + + {:ok, first_goal} = DonationGoalsManager.create(%{amount: 10, description: "Test", project_id: project.id}) + + # total donated is 0, + # only goal inserted is the first goal + assert_current_goal_id(first_goal.id) + + {:ok, second_goal} = DonationGoalsManager.create(%{amount: 20, description: "Test", project_id: project.id}) + + # total donated is still 0 + # first goal larger than 0 is the very first goal + assert_current_goal_id(first_goal.id) + + insert(:stripe_connect_subscription, quantity: 15, stripe_connect_plan: plan) + + {:ok, _} = DonationGoalsManager.create(%{amount: 30, description: "Test", project_id: project.id}) + + # total donated is 15. + # first applicable goal is second goal, with an amount of 20 + # third goal has an amount of 30, which is not applicable + assert_current_goal_id(second_goal.id) + + insert(:stripe_connect_subscription, quantity: 30, stripe_connect_plan: plan) + + {:ok, fourth_goal} = DonationGoalsManager.create(%{amount: 40, description: "Test", project_id: project.id}) + + # total donated is 45, which is more than any defined goal + # largest goal inserted after change the fourth goal, with an amount of 40 + assert_current_goal_id(fourth_goal.id) + end + end + + describe "update/2" do + test "updates existing goal, returns {:ok, record}" do + project = insert(:project) + insert(:stripe_connect_plan, project: project) + donation_goal = insert(:donation_goal, amount: 10, project: project) + + {:ok, %DonationGoal{} = updated_goal} = DonationGoalsManager.update(donation_goal, %{amount: 15}) + assert_current_goal_id(updated_goal.id) + assert updated_goal.id == donation_goal.id + end + test "returns {:error, changeset} if there are validation errors" do + project = insert(:project) + insert(:stripe_connect_plan, project: project) + donation_goal = insert(:donation_goal, amount: 10, project: project) + + {:error, %Ecto.Changeset{} = changeset} = DonationGoalsManager.update(donation_goal, %{amount: nil}) + refute changeset.valid? + end + + test "sets current goal correctly" do + project = insert(:project) + plan = insert(:stripe_connect_plan, project: project) + goal_1 = insert(:donation_goal, amount: 10, project: project) + goal_2 = insert(:donation_goal, amount: 15, project: project) + insert(:donation_goal, amount: 20, project: project) + + DonationGoalsManager.update(goal_1, %{amount: 11}) + + # amount donated is 0, first goal above that is still goal 1 + assert_current_goal_id(goal_1.id) + + DonationGoalsManager.update(goal_1, %{amount: 21}) + + # amount donated is still 0, first goal above that is now goal 2 + assert_current_goal_id(goal_2.id) + + insert(:stripe_connect_subscription, quantity: 25, stripe_connect_plan: plan) + + DonationGoalsManager.update(goal_1, %{amount: 21}) + + # amount donated is now 25 + # this is more than any current goal + # largest goal is goal 1, with 21 + assert_current_goal_id(goal_1.id) + + DonationGoalsManager.update(goal_2, %{amount: 22}) + + # amount donated is now 25 + # this is more than any current goal + # largest goal is goal 2, with 22 + assert_current_goal_id(goal_2.id) + + DonationGoalsManager.update(goal_1, %{amount: 27}) + + # amount donated is still 25 + # first goal higher than that is goal 1, with 27 + assert_current_goal_id(goal_1.id) + end + end + + + + describe "set_current_goal_for_project/1" do + test "sets current goal correctly" do + project = insert(:project) + plan = insert(:stripe_connect_plan, project: project) + + goal_1 = insert(:donation_goal, amount: 10, project: project) + goal_2 = insert(:donation_goal, amount: 15, project: project) + goal_3 = insert(:donation_goal, amount: 20, project: project) + + plan |> donate(5) + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_1.id) + + plan |> donate(5) # total is now 10 + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_2.id) + + plan |> donate(5) # total is now 15 + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_3.id) + + plan |> donate(5) # total is now 20 + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_3.id) + + plan |> donate(5) # total is now 25 + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_3.id) + + goal_4 = insert(:donation_goal, amount: 30, project: project) # 30 is more than the current 25 total + DonationGoalsManager.set_current_goal_for_project(project) + assert_current_goal_id(goal_4.id) + end + end +end diff --git a/test/models/donation_goal_test.exs b/test/models/donation_goal_test.exs index fa718a57c..65a557d8e 100644 --- a/test/models/donation_goal_test.exs +++ b/test/models/donation_goal_test.exs @@ -4,18 +4,17 @@ defmodule CodeCorps.DonationGoalTest do alias CodeCorps.DonationGoal describe "%create_changeset/2" do - test "requires amount, current, description and project_id" do + test "requires amount, description and project_id" do changeset = DonationGoal.create_changeset(%DonationGoal{}, %{}) refute changeset.valid? assert changeset.errors[:amount] == {"can't be blank", []} - assert changeset.errors[:current] == {"can't be blank", []} assert changeset.errors[:description] == {"can't be blank", []} assert changeset.errors[:project_id] == {"can't be blank", []} end test "ensures project with specified id actually exists" do - attrs = %{amount: 100, current: true, description: "Bar", project_id: -1} + attrs = %{amount: 100, description: "Bar", project_id: -1} { result, changeset } = DonationGoal.create_changeset(%DonationGoal{}, attrs) |> Repo.insert @@ -27,14 +26,13 @@ defmodule CodeCorps.DonationGoalTest do end describe "&update_changeset/2" do - test "requires amount, current, description" do - attrs = %{amount: nil, current: nil, description: nil} + test "requires amount, description" do + attrs = %{amount: nil, description: nil} donation_goal = insert(:donation_goal) changeset = DonationGoal.update_changeset(donation_goal, attrs) refute changeset.valid? assert changeset.errors[:amount] == {"can't be blank", []} - assert changeset.errors[:current] == {"can't be blank", []} assert changeset.errors[:description] == {"can't be blank", []} end end diff --git a/test/models/project_test.exs b/test/models/project_test.exs index ab70802d0..f9f11cd31 100644 --- a/test/models/project_test.exs +++ b/test/models/project_test.exs @@ -6,7 +6,7 @@ defmodule CodeCorps.ProjectTest do @valid_attrs %{title: "A title"} @invalid_attrs %{} - describe "changeset/3" do + describe "changeset/2" do test "with valid attributes is valid" do changeset = Project.changeset(%Project{}, @valid_attrs) assert changeset.valid? @@ -60,17 +60,45 @@ defmodule CodeCorps.ProjectTest do end end - describe "create_changeset/3" do + describe "create_changeset/2" do test "accepts setting of organization_id" do changeset = Project.create_changeset(%Project{}, %{organization_id: 1}) assert {:ok, 1} == changeset |> fetch_change(:organization_id) end end - describe "update_changeset/3" do + describe "update_changeset/2" do test "rejects setting of organization id" do changeset = Project.update_changeset(%Project{}, %{organization_id: 1}) assert :error == changeset |> fetch_change(:organization_id) end end + + describe "set_current_donation_goal_changeset/2" do + test "requires current_donation_goal_id" do + changeset = Project.set_current_donation_goal_changeset(%Project{}, %{}) + refute changeset.valid? + + assert changeset.errors[:current_donation_goal_id] == {"can't be blank", []} + end + + test "accepts setting of current_donation_goal_id" do + changeset = Project.set_current_donation_goal_changeset(%Project{}, %{current_donation_goal_id: 1}) + assert {:ok, 1} == changeset |> fetch_change(:current_donation_goal_id) + end + + test "ensures associations link to records that exist" do + project = insert(:project) + attrs = %{current_donation_goal_id: -1} + + { result, changeset } = + project + |> Project.set_current_donation_goal_changeset(attrs) + |> Repo.update + + assert result == :error + refute changeset.valid? + assert changeset.errors[:current_donation_goal] == {"does not exist", []} + end + end end diff --git a/test/support/factories.ex b/test/support/factories.ex index 0d71c874c..81a990874 100644 --- a/test/support/factories.ex +++ b/test/support/factories.ex @@ -23,7 +23,6 @@ defmodule CodeCorps.Factories do def donation_goal_factory do %CodeCorps.DonationGoal{ amount: 100, - current: false, description: sequence(:description, &"A description for a donation goal #{&1}"), project: build(:project) } diff --git a/test/views/donation_goal_view_test.exs b/test/views/donation_goal_view_test.exs index a7b527108..851336654 100644 --- a/test/views/donation_goal_view_test.exs +++ b/test/views/donation_goal_view_test.exs @@ -15,7 +15,6 @@ defmodule CodeCorps.DonationGoalViewTest do "type" => "donation-goal", "attributes" => %{ "amount" => donation_goal.amount, - "current" => donation_goal.current, "description" => donation_goal.description }, "relationships" => %{ diff --git a/test/views/project_view_test.exs b/test/views/project_view_test.exs index 5fb4aa2cd..76dd86f03 100644 --- a/test/views/project_view_test.exs +++ b/test/views/project_view_test.exs @@ -3,6 +3,10 @@ defmodule CodeCorps.ProjectViewTest do import Phoenix.View, only: [render: 3] + def set_current_donation_goal(project, donation_goal) do + %{project | current_donation_goal_id: donation_goal.id} + end + test "renders all attributes and relationships properly" do organization = insert(:organization) project = insert(:project, organization: organization) @@ -13,6 +17,8 @@ defmodule CodeCorps.ProjectViewTest do stripe_connect_plan = insert(:stripe_connect_plan, project: project) task = insert(:task, project: project) + project = project |> set_current_donation_goal(donation_goal) + rendered_json = render(CodeCorps.ProjectView, "show.json-api", data: project) expected_json = %{ @@ -30,6 +36,12 @@ defmodule CodeCorps.ProjectViewTest do }, "id" => project.id |> Integer.to_string, "relationships" => %{ + "current-donation-goal" => %{ + "data" => %{ + "id" => donation_goal.id |> Integer.to_string, + "type" => "donation-goal" + } + }, "donation-goals" => %{"data" => [ %{ "id" => donation_goal.id |> Integer.to_string, diff --git a/web/controllers/donation_goal_controller.ex b/web/controllers/donation_goal_controller.ex index 62c1aee69..3d6b5603e 100644 --- a/web/controllers/donation_goal_controller.ex +++ b/web/controllers/donation_goal_controller.ex @@ -5,6 +5,7 @@ defmodule CodeCorps.DonationGoalController do import CodeCorps.Helpers.Query, only: [id_filter: 2] alias CodeCorps.DonationGoal + alias CodeCorps.DonationGoalsManager plug :load_and_authorize_changeset, model: DonationGoal, only: [:create] plug :load_and_authorize_resource, model: DonationGoal, only: [:update, :delete] @@ -13,14 +14,12 @@ defmodule CodeCorps.DonationGoalController do def filter(_conn, query, "id", id_list), do: id_filter(query, id_list) def handle_create(_conn, attributes) do - %DonationGoal{} - |> DonationGoal.create_changeset(attributes) - |> Repo.insert + attributes + |> DonationGoalsManager.create end def handle_update(_conn, record, attributes) do record - |> DonationGoal.update_changeset(attributes) - |> Repo.update + |> DonationGoalsManager.update(attributes) end end diff --git a/web/models/donation_goal.ex b/web/models/donation_goal.ex index ed8a1e130..f3bbeb101 100644 --- a/web/models/donation_goal.ex +++ b/web/models/donation_goal.ex @@ -12,7 +12,6 @@ defmodule CodeCorps.DonationGoal do schema "donation_goals" do field :amount, :integer - field :current, :boolean field :description, :string belongs_to :project, CodeCorps.Project @@ -25,8 +24,8 @@ defmodule CodeCorps.DonationGoal do """ def create_changeset(struct, params \\ %{}) do struct - |> cast(params, [:amount, :current, :description, :project_id]) - |> validate_required([:amount, :current, :description, :project_id]) + |> cast(params, [:amount, :description, :project_id]) + |> validate_required([:amount, :description, :project_id]) |> assoc_constraint(:project) end @@ -35,7 +34,7 @@ defmodule CodeCorps.DonationGoal do """ def update_changeset(struct, params \\ %{}) do struct - |> cast(params, [:amount, :current, :description]) - |> validate_required([:amount, :current, :description]) + |> cast(params, [:amount, :description]) + |> validate_required([:amount, :description]) end end diff --git a/web/models/project.ex b/web/models/project.ex index 290c3bb1a..00c55d262 100644 --- a/web/models/project.ex +++ b/web/models/project.ex @@ -19,7 +19,9 @@ defmodule CodeCorps.Project do field :slug, :string field :title, :string + belongs_to :current_donation_goal, CodeCorps.DonationGoal belongs_to :organization, CodeCorps.Organization + has_one :stripe_connect_plan, CodeCorps.StripeConnectPlan has_many :donation_goals, CodeCorps.DonationGoal @@ -29,6 +31,7 @@ defmodule CodeCorps.Project do has_many :categories, through: [:project_categories, :category] has_many :skills, through: [:project_skills, :skill] + has_many :stripe_connect_subscriptions, through: [:stripe_connect_plan, :stripe_connect_subscriptions] timestamps() end @@ -61,4 +64,15 @@ defmodule CodeCorps.Project do struct |> changeset(params) end + + @doc """ + Builds a changeset for setting the current donation goal. + """ + def set_current_donation_goal_changeset(struct, params) do + struct + |> cast(params, [:current_donation_goal_id]) + |> validate_required(:current_donation_goal_id) + |> assoc_constraint(:current_donation_goal) + |> changeset(params) + end end diff --git a/web/views/donation_goal_view.ex b/web/views/donation_goal_view.ex index e9a99fdba..b2e7869fd 100644 --- a/web/views/donation_goal_view.ex +++ b/web/views/donation_goal_view.ex @@ -3,7 +3,7 @@ defmodule CodeCorps.DonationGoalView do use CodeCorps.Web, :view use JaSerializer.PhoenixView - attributes [:amount, :current, :description] + attributes [:amount, :description] has_one :project, serializer: CodeCorps.ProjectView end diff --git a/web/views/project_view.ex b/web/views/project_view.ex index 24f45d90b..f29aa166f 100644 --- a/web/views/project_view.ex +++ b/web/views/project_view.ex @@ -1,8 +1,8 @@ defmodule CodeCorps.ProjectView do use CodeCorps.PreloadHelpers, default_preloads: [ - :donation_goals, :organization, :project_categories, - :stripe_connect_plan, :project_skills, :tasks + :current_donation_goal, :donation_goals, :organization, + :project_categories, :stripe_connect_plan, :project_skills, :tasks ] use CodeCorps.Web, :view use JaSerializer.PhoenixView @@ -12,6 +12,7 @@ defmodule CodeCorps.ProjectView do :long_description_body, :long_description_markdown, :inserted_at, :updated_at] + has_one :current_donation_goal, serializer: CodeCorps.DonationGoalView has_one :organization, serializer: CodeCorps.OrganizationView has_one :stripe_connect_plan, serializer: CodeCorps.StripeConnectPlanView