diff --git a/.iex.exs b/.iex.exs index e346ca507..1d8700b39 100644 --- a/.iex.exs +++ b/.iex.exs @@ -9,7 +9,9 @@ alias Algora.Admin alias Algora.Admin.Migration alias Algora.Analytics alias Algora.Bounties +alias Algora.Bounties.Bounty alias Algora.Bounties.Claim +alias Algora.Bounties.Tip alias Algora.Contracts alias Algora.Contracts.Contract alias Algora.Contracts.Timesheet diff --git a/lib/algora/activities/discord_views.ex b/lib/algora/activities/discord_views.ex index 62430ae45..f6529f1d9 100644 --- a/lib/algora/activities/discord_views.ex +++ b/lib/algora/activities/discord_views.ex @@ -45,5 +45,42 @@ defmodule Algora.Activities.DiscordViews do } end + def render(%{type: :transaction_succeeded, assoc: tx}) do + tx = Repo.preload(tx, [:user, linked_transaction: [:user]]) + + %{ + embeds: [ + %{ + color: 0x6366F1, + title: "#{tx.net_amount} paid!", + author: %{ + name: tx.linked_transaction.user.name, + icon_url: tx.linked_transaction.user.avatar_url, + url: "#{AlgoraWeb.Endpoint.url()}/org/#{tx.linked_transaction.user.handle}" + }, + footer: %{ + text: tx.user.name, + icon_url: tx.user.avatar_url + }, + thumbnail: %{url: tx.user.avatar_url}, + fields: [ + %{ + name: "Sender", + value: tx.linked_transaction.user.name, + inline: false + }, + %{ + name: "Recipient", + value: tx.user.name, + inline: false + } + ], + url: "#{AlgoraWeb.Endpoint.url()}/org/#{tx.linked_transaction.user.handle}", + timestamp: tx.succeeded_at + } + ] + } + end + def render(_activity), do: nil end diff --git a/lib/algora/activities/schemas/activity.ex b/lib/algora/activities/schemas/activity.ex index 9111da076..815a7edf2 100644 --- a/lib/algora/activities/schemas/activity.ex +++ b/lib/algora/activities/schemas/activity.ex @@ -5,17 +5,15 @@ defmodule Algora.Activities.Activity do require Protocol @activity_types ~w{ - contract_paid contract_prepaid contract_created contract_renewed identity_created - bounty_awarded bounty_posted bounty_repriced claim_submitted claim_approved - tip_awarded + transaction_succeeded }a typed_schema "activities" do diff --git a/lib/algora/activities/views.ex b/lib/algora/activities/views.ex index 8caf58808..f1f2bba53 100644 --- a/lib/algora/activities/views.ex +++ b/lib/algora/activities/views.ex @@ -2,6 +2,8 @@ defmodule Algora.Activities.Views do @moduledoc false alias Algora.Repo + require Logger + def render(%{type: type} = activity, template) when is_binary(type) do render(%{activity | type: String.to_existing_atom(type)}, template) end @@ -18,31 +20,16 @@ defmodule Algora.Activities.Views do """ end - def render(%{type: :bounty_awarded, assoc: bounty}, :title) do - bounty = Repo.preload(bounty, :creator) - "🎉 #{bounty.amount} bounty awarded by #{bounty.creator.display_name}" - end - - def render(%{type: :bounty_awarded, assoc: bounty} = activity, :txt) do - bounty = Repo.preload(bounty, :creator) - - """ - Congratulations, you've been awarded a bounty by #{bounty.creator.display_name}! - - #{Algora.Activities.external_url(activity)} - """ - end - def render(%{type: :bounty_posted, assoc: bounty}, :title) do bounty = Repo.preload(bounty, :creator) - "#{bounty.amount} bounty posted by #{bounty.creator.display_name}" + "#{bounty.amount} bounty posted by #{bounty.creator.name}" end def render(%{type: :bounty_posted, assoc: bounty} = activity, :txt) do bounty = Repo.preload(bounty, :creator) """ - A new bounty has been posted by #{bounty.creator.display_name} + A new bounty has been posted by #{bounty.creator.name} #{Algora.Activities.external_url(activity)} """ @@ -102,21 +89,7 @@ defmodule Algora.Activities.Views do contract = Repo.preload(contract, [:client, :contractor]) """ - A contract between #{contract.client.display_name} and #{contract.contractor.display_name} has been created. - - #{Algora.Activities.external_url(activity)} - """ - end - - def render(%{type: :contract_paid, assoc: _contract}, :title) do - "A contract has been paid on Algora" - end - - def render(%{type: :contract_paid, assoc: contract} = activity, :txt) do - contract = Repo.preload(contract, [:client, :contractor]) - - """ - A contract between "#{contract.client.display_name}" and "#{contract.contractor.display_name}" has been paid. + A contract between #{contract.client.name} and #{contract.contractor.name} has been created. #{Algora.Activities.external_url(activity)} """ @@ -130,7 +103,7 @@ defmodule Algora.Activities.Views do contract = Repo.preload(contract, :client) """ - A contract for "#{contract.client.display_name}" has been prepaid. + A contract for "#{contract.client.name}" has been prepaid. #{Algora.Activities.external_url(activity)} """ @@ -144,23 +117,63 @@ defmodule Algora.Activities.Views do contract = Repo.preload(contract, [:client, :contractor]) """ - A contract between "#{contract.client.display_name}" and "#{contract.contractor.display_name}" has been renewed. + A contract between "#{contract.client.name}" and "#{contract.contractor.name}" has been renewed. #{Algora.Activities.external_url(activity)} """ end - def render(%{type: :tip_awarded, assoc: _tip}, :title) do - "You were awarded a tip on Algora" + def render(%{type: :transaction_succeeded, assoc: tx} = activity, template) do + tx = Repo.preload(tx, [:user, linked_transaction: [:user]]) + activity = %{activity | assoc: tx} + + case tx do + %{linked_transaction: nil} -> + Logger.error("Unknown transaction type: #{inspect(tx)}") + raise "Unknown transaction type: #{inspect(tx)}" + + %{bounty_id: bounty_id} when not is_nil(bounty_id) -> + render_transaction_succeeded(activity, template, :bounty) + + %{tip_id: tip_id} when not is_nil(tip_id) -> + render_transaction_succeeded(activity, template, :tip) + + %{contract_id: contract_id} when not is_nil(contract_id) -> + render_transaction_succeeded(activity, template, :contract) + + _ -> + Logger.error("Unknown transaction type: #{inspect(tx)}") + raise "Unknown transaction type: #{inspect(tx)}" + end + end + + defp render_transaction_succeeded(%{assoc: tx}, :title, :bounty) do + "🎉 #{tx.net_amount} bounty awarded by #{tx.linked_transaction.user.name}" + end + + defp render_transaction_succeeded(%{assoc: tx}, :title, :tip) do + "💸 #{tx.net_amount} tip received from #{tx.linked_transaction.user.name}" end - def render(%{type: :tip_awarded, assoc: tip} = activity, :txt) do - tip = Repo.preload(tip, :creator) + defp render_transaction_succeeded(%{assoc: tx}, :title, :contract) do + "💰 #{tx.net_amount} contract paid by #{tx.linked_transaction.user.name}" + end + defp render_transaction_succeeded(%{assoc: tx}, :txt, :bounty) do + """ + Congratulations, you've been awarded a #{tx.net_amount} bounty by #{tx.linked_transaction.user.name}! """ - #{tip.creator.display_name} sent you a #{tip.amount} tip on Algora! + end - #{Algora.Activities.external_url(activity)} + defp render_transaction_succeeded(%{assoc: tx}, :txt, :tip) do + """ + Congratulations, you've been awarded a #{tx.net_amount} tip by #{tx.linked_transaction.user.name}! + """ + end + + defp render_transaction_succeeded(%{assoc: tx}, :txt, :contract) do + """ + Congratulations, you've been awarded a #{tx.net_amount} contract by #{tx.linked_transaction.user.name}! """ end end diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index a600b50e5..69d3fc327 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -678,10 +678,7 @@ defmodule Algora.Bounties do recipient_id: recipient.id, ticket_id: if(ticket, do: ticket.id) }) - |> Repo.insert_with_activity(%{ - type: :tip_awarded, - notify_users: [] - }) + |> Repo.insert() end end @@ -696,18 +693,12 @@ defmodule Algora.Bounties do ) :: {:ok, String.t()} | {:error, atom()} def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do - Repo.transact(fn -> - activity_attrs = %{type: :bounty_awarded} - - with {:ok, _activity} <- Algora.Activities.insert(%Bounty{id: bounty_id}, activity_attrs) do - create_payment_session( - %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, - ticket_ref: opts[:ticket_ref], - bounty_id: bounty_id, - claims: claims - ) - end - end) + create_payment_session( + %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, + ticket_ref: opts[:ticket_ref], + bounty_id: bounty_id, + claims: claims + ) end @spec generate_line_items( diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index 2e4f6f8be..5a599069d 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -539,12 +539,7 @@ defmodule Algora.Contracts do end defp mark_contract_as_paid(contract) do - contract - |> change(%{status: :paid}) - |> Repo.update_with_activity(%{ - type: :contract_paid, - notify_users: [] - }) + change(contract, %{status: :paid}) end defp renew_contract(contract) do diff --git a/lib/algora/repo.ex b/lib/algora/repo.ex index ecba626b7..9ce15dc44 100644 --- a/lib/algora/repo.ex +++ b/lib/algora/repo.ex @@ -3,6 +3,9 @@ defmodule Algora.Repo do otp_app: :algora, adapter: Ecto.Adapters.Postgres + alias Algora.Activities.Activity + alias Algora.Activities.Notifier + require Ecto.Query @spec fetch_one(Ecto.Queryable.t(), Keyword.t()) :: @@ -105,13 +108,22 @@ defmodule Algora.Repo do def with_activity(multi, activity) do multi |> Ecto.Multi.insert(:activity, fn %{target: target} -> - Algora.Activities.Activity.build_activity(target, Map.put(activity, :id, target.id)) + Activity.build_activity(target, Map.put(activity, :id, target.id)) end) |> Oban.insert(:notification, fn %{activity: activity, target: target} -> - Algora.Activities.Notifier.changeset(activity, target) + Notifier.changeset(activity, target) end) end + def insert_activity(target, activity) do + activity = Map.put(activity, :id, target.id) + + with {:ok, activity} <- Algora.Repo.insert(Activity.build_activity(target, activity)), + {:ok, notification} <- Oban.insert(Notifier.changeset(activity, target)) do + {:ok, %{activity: activity, notification: notification}} + end + end + defp extract_target(response) do case response do {:ok, %{target: target}} -> diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 821fc3e6b..64a7e394b 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -66,6 +66,16 @@ defmodule AlgoraWeb.Webhooks.StripeController do Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) + activities_result = + txs + |> Enum.filter(&(&1.type == :credit)) + |> Enum.reduce_while(:ok, fn tx, :ok -> + case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do + {:ok, _} -> {:cont, :ok} + error -> {:halt, error} + end + end) + jobs_result = txs |> Enum.filter(&(&1.type == :credit)) @@ -90,6 +100,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do end) with txs when txs != [] <- txs, + :ok <- activities_result, :ok <- jobs_result do Payments.broadcast() {:ok, nil} diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index 9d4b39bef..a2e264871 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -93,16 +93,11 @@ defmodule Algora.BountiesTest do claims: claims ) - assert_activity_names([:bounty_posted, :claim_submitted, :bounty_awarded, :tip_awarded]) - assert_activity_names_for_user(creator.id, [:bounty_posted, :bounty_awarded, :tip_awarded]) - assert_activity_names_for_user(recipient.id, [:claim_submitted, :tip_awarded]) - - assert [bounty, _claim, _awarded, tip] = Enum.reverse(Algora.Activities.all()) - assert "tip_activities" == tip.assoc_name - # assert tip.notify_users == [recipient.id] - assert activity = Algora.Activities.get_with_preloaded_assoc(tip.assoc_name, tip.id) - assert activity.assoc.__meta__.schema == Tip - assert activity.assoc.creator.id == creator.id + assert_activity_names([:bounty_posted, :claim_submitted]) + assert_activity_names_for_user(creator.id, [:bounty_posted]) + assert_activity_names_for_user(recipient.id, [:claim_submitted]) + + assert [bounty, _claim] = Enum.reverse(Algora.Activities.all()) assert_enqueued(worker: Notifier, args: %{"activity_id" => bounty.id}) refute_enqueued(worker: SendEmail, args: %{"activity_id" => bounty.id}) diff --git a/test/algora/contracts_test.exs b/test/algora/contracts_test.exs index d64856325..5b8dfb216 100644 --- a/test/algora/contracts_test.exs +++ b/test/algora/contracts_test.exs @@ -160,26 +160,22 @@ defmodule Algora.ContractsTest do assert_activity_names( contract_a_0, - [:contract_prepaid, :contract_paid] + [:contract_prepaid] ) assert_activity_names( contract_a_1, - [:contract_renewed, :contract_paid] + [:contract_renewed] ) assert_activity_names( "contract_activities", [ :contract_prepaid, - :contract_paid, :contract_renewed, - :contract_paid, :contract_renewed, :contract_prepaid, - :contract_paid, :contract_renewed, - :contract_paid, :contract_renewed ] ) @@ -188,9 +184,7 @@ defmodule Algora.ContractsTest do contract_a_0.contractor_id, [ :contract_prepaid, - :contract_paid, :contract_renewed, - :contract_paid, :contract_renewed ] ) diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index d1043f518..b7760b721 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -5,6 +5,8 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do import Algora.Factory import Ecto.Query + alias Algora.Activities.Notifier + alias Algora.Activities.SendEmail alias Algora.Bounties alias Algora.Bounties.Bounty alias Algora.Bounties.Tip @@ -27,41 +29,40 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do describe "handle_event/1 for charge.succeeded" do test "updates transaction statuses and marks associated records as paid", %{ metadata: metadata, - sender: sender, - recipient: recipient + sender: sender } do group_id = "#{Algora.Util.random_int()}" + recipient1 = insert(:user) + recipient2 = insert(:user) + recipient3 = insert(:user) bounty = insert(:bounty, status: :open, owner_id: sender.id, ticket: insert(:ticket)) - tip = insert(:tip, status: :open, owner_id: sender.id, recipient: recipient) - contract = insert(:contract, status: :active, client: sender, contractor: recipient) - - bounty_credit_tx = - insert(:transaction, %{ - type: :credit, - status: :initialized, - group_id: group_id, - user_id: recipient.id, - bounty_id: bounty.id - }) - - tip_credit_tx = - insert(:transaction, %{ - type: :credit, - status: :initialized, - group_id: group_id, - user_id: recipient.id, - tip_id: tip.id - }) - - contract_credit_tx = - insert(:transaction, %{ - type: :credit, - status: :initialized, - group_id: group_id, - user_id: recipient.id, - contract_id: contract.id - }) + tip = insert(:tip, status: :open, owner_id: sender.id, recipient: recipient2) + contract = insert(:contract, status: :active, client: sender, contractor: recipient3) + + %{credit: bounty_credit_tx} = + insert_transaction_pair( + sender_id: sender.id, + recipient_id: recipient1.id, + bounty_id: bounty.id, + group_id: group_id + ) + + %{credit: tip_credit_tx} = + insert_transaction_pair( + sender_id: sender.id, + recipient_id: recipient2.id, + tip_id: tip.id, + group_id: group_id + ) + + %{credit: contract_credit_tx} = + insert_transaction_pair( + sender_id: sender.id, + recipient_id: recipient3.id, + contract_id: contract.id, + group_id: group_id + ) event = %Stripe.Event{ type: "charge.succeeded", @@ -81,6 +82,24 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do assert Repo.get(Bounty, bounty.id).status == :paid assert Repo.get(Tip, tip.id).status == :paid assert Repo.get(Contract, contract.id).status == :paid + + assert_activity_names([:transaction_succeeded, :transaction_succeeded, :transaction_succeeded]) + + assert_activity_names_for_user(recipient1.id, [:transaction_succeeded]) + assert_activity_names_for_user(recipient2.id, [:transaction_succeeded]) + assert_activity_names_for_user(recipient3.id, [:transaction_succeeded]) + + assert [bounty_tx, tip_tx, contract_tx] = Enum.reverse(Algora.Activities.all()) + + assert_enqueued(worker: Notifier, args: %{"activity_id" => bounty_tx.id}) + assert_enqueued(worker: Notifier, args: %{"activity_id" => tip_tx.id}) + assert_enqueued(worker: Notifier, args: %{"activity_id" => contract_tx.id}) + + Enum.map(all_enqueued(worker: Notifier), fn job -> perform_job(Notifier, job.args) end) + + assert_enqueued(worker: SendEmail, args: %{"activity_id" => bounty_tx.id}) + assert_enqueued(worker: SendEmail, args: %{"activity_id" => tip_tx.id}) + assert_enqueued(worker: SendEmail, args: %{"activity_id" => contract_tx.id}) end test "updates transaction status and enqueues PromptPayoutConnect job", %{ diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index c86ef3b36..556a1668f 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -35,4 +35,24 @@ defmodule AlgoraWeb.ConnCase do Algora.DataCase.setup_sandbox(tags) {:ok, conn: Phoenix.ConnTest.build_conn()} end + + def assert_activity_names(names) do + assert Algora.Activities.all() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end + + def assert_activity_names(target, names) do + assert target + |> Algora.Activities.all() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end + + def assert_activity_names_for_user(user_id, names) do + assert user_id + |> Algora.Activities.all_for_user() + |> Enum.reverse() + |> Enum.map(&Map.get(&1, :type)) == names + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 702cd9861..8046abde8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -146,6 +146,41 @@ defmodule Algora.Factory do } end + def insert_transaction_pair(opts) do + credit_id = Nanoid.generate() + debit_id = Nanoid.generate() + + debit_tx = + insert( + :transaction, + id: debit_id, + linked_transaction_id: credit_id, + status: :initialized, + type: :debit, + user_id: opts[:sender_id], + bounty_id: opts[:bounty_id], + tip_id: opts[:tip_id], + contract_id: opts[:contract_id], + group_id: opts[:group_id] + ) + + credit_tx = + insert( + :transaction, + id: credit_id, + linked_transaction_id: debit_id, + status: :initialized, + type: :credit, + user_id: opts[:recipient_id], + bounty_id: opts[:bounty_id], + tip_id: opts[:tip_id], + contract_id: opts[:contract_id], + group_id: opts[:group_id] + ) + + %{debit: debit_tx, credit: credit_tx} + end + def timesheet_factory do %Algora.Contracts.Timesheet{ id: Nanoid.generate(),