diff --git a/.iex.exs b/.iex.exs index 936c5f23a..1c9b0078b 100644 --- a/.iex.exs +++ b/.iex.exs @@ -18,6 +18,7 @@ alias Algora.Payments.Customer alias Algora.Payments.PaymentMethod alias Algora.Payments.Transaction alias Algora.Repo +alias Algora.Workspace.Ticket IEx.configure(inspect: [charlists: :as_lists, limit: :infinity], auto_reload: true) diff --git a/config/config.exs b/config/config.exs index 402ff8b8b..ae65e6fce 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,6 +34,8 @@ config :algora, Oban, notify_bounty: 1, notify_tip_intent: 1, notify_claim: 1, + notify_transfer: 100, + prompt_payout_connect: 100, transfers: 1, activity_notifier: 1, activity_mailer: 1 diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex new file mode 100644 index 000000000..20f7913ed --- /dev/null +++ b/lib/algora/bounties/jobs/notify_transfer.ex @@ -0,0 +1,65 @@ +defmodule Algora.Bounties.Jobs.NotifyTransfer do + @moduledoc false + use Oban.Worker, queue: :notify_transfer + + import Ecto.Query + + alias Algora.Bounties.Ticket + alias Algora.Github + alias Algora.Payments.Transaction + alias Algora.Repo + alias Algora.Workspace.Installation + alias Algora.Workspace.Ticket + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"transafer_id" => transafer_id}}) do + with {:ok, ticket} <- + Repo.fetch_one( + from t in Ticket, + left_join: bounty in assoc(t, :bounties), + left_join: tip in assoc(t, :tips), + left_join: tx in Transaction, + on: tx.bounty_id == bounty.id or tx.tip_id == tip.id, + join: repo in assoc(t, :repository), + join: user in assoc(repo, :user), + where: tx.id == ^transafer_id, + select_merge: %{ + repository: %{repo | user: user} + } + ), + ticket_ref = %{ + owner: ticket.repository.user.provider_login, + repo: ticket.repository.name, + number: ticket.number + }, + {:ok, transaction} <- + Repo.fetch_one( + from tx in Transaction, + join: user in assoc(tx, :user), + where: tx.id == ^transafer_id, + select_merge: %{user: user} + ) do + installation = Repo.get_by(Installation, provider_user_id: ticket.repository.user.id) + body = "🎉🎈 @#{transaction.user.provider_login} has been awarded **#{transaction.net_amount}**! 🎈🎊" + + do_perform(ticket_ref, body, installation) + end + end + + defp do_perform(ticket_ref, body, nil) do + Github.try_without_installation(&Github.create_issue_comment/5, [ + ticket_ref.owner, + ticket_ref.repo, + ticket_ref.number, + body + ]) + end + + defp do_perform(ticket_ref, body, installation) do + with {:ok, token} <- Github.get_installation_token(installation.provider_id) do + Github.create_issue_comment(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, body) + end + end +end diff --git a/lib/algora/bounties/jobs/prompt_payout_connect.ex b/lib/algora/bounties/jobs/prompt_payout_connect.ex new file mode 100644 index 000000000..3354d8506 --- /dev/null +++ b/lib/algora/bounties/jobs/prompt_payout_connect.ex @@ -0,0 +1,84 @@ +defmodule Algora.Bounties.Jobs.PromptPayoutConnect do + @moduledoc false + use Oban.Worker, queue: :prompt_payout_connect + + import Ecto.Query + + alias Algora.Bounties.Ticket + alias Algora.Github + alias Algora.Payments.Transaction + alias Algora.Repo + alias Algora.Workspace.Installation + alias Algora.Workspace.Ticket + + require Logger + + # TODO: confirm url + @onboarding_url "https://console.algora.io/solve" + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"credit_id" => credit_id}}) do + with {:ok, ticket} <- + Repo.fetch_one( + from t in Ticket, + left_join: bounty in assoc(t, :bounties), + left_join: tip in assoc(t, :tips), + left_join: tx in Transaction, + on: tx.bounty_id == bounty.id or tx.tip_id == tip.id, + join: repo in assoc(t, :repository), + join: user in assoc(repo, :user), + where: tx.id == ^credit_id, + select_merge: %{ + repository: %{repo | user: user} + } + ), + ticket_ref = %{ + owner: ticket.repository.user.provider_login, + repo: ticket.repository.name, + number: ticket.number + }, + {:ok, transaction} <- + Repo.fetch_one( + from tx in Transaction, + join: user in assoc(tx, :user), + left_join: linked_tx in Transaction, + on: linked_tx.id == tx.linked_transaction_id, + left_join: sender in assoc(linked_tx, :user), + where: tx.id == ^credit_id, + select_merge: %{ + user: user, + linked_transaction: %{linked_tx | user: sender} + } + ) do + installation = Repo.get_by(Installation, provider_user_id: ticket.repository.user.id) + + reward_type = + cond do + transaction.tip_id -> "tip" + transaction.bounty_id -> "bounty" + transaction.contract_id -> "contract" + true -> raise "Unknown transaction type" + end + + body = + "@#{transaction.user.provider_login}: You've been awarded a **#{transaction.net_amount}** #{reward_type} #{if transaction.linked_transaction, do: "by **#{transaction.linked_transaction.user.name}**", else: ""}! 👉 [Complete your Algora onboarding](#{@onboarding_url}) to collect the #{reward_type}." + + do_perform(ticket_ref, body, installation) + end + end + + defp do_perform(ticket_ref, body, nil) do + Github.try_without_installation(&Github.create_issue_comment/5, [ + ticket_ref.owner, + ticket_ref.repo, + ticket_ref.number, + body + ]) + end + + defp do_perform(ticket_ref, body, installation) do + with {:ok, token} <- Github.get_installation_token(installation.provider_id) do + Github.create_issue_comment(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, body) + end + end +end diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index dc19966ce..2a0480efc 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -2,6 +2,8 @@ defmodule Algora.Github do @moduledoc false @behaviour Algora.Github.Behaviour + require Logger + @type token :: String.t() def client_id, do: Algora.config([:github, :client_id]) @@ -43,6 +45,27 @@ defmodule Algora.Github do defp client, do: Application.get_env(:algora, :github_client, Algora.Github.Client) + def try_without_installation(function, args) do + if pat_enabled() do + apply(function, [pat() | args]) + else + {_, module} = Function.info(function, :module) + {_, name} = Function.info(function, :name) + function_name = String.trim_leading("#{module}.#{name}", "Elixir.") + + formatted_args = + Enum.map_join(args, ", ", fn + arg when is_binary(arg) -> "\"#{arg}\"" + arg -> "#{arg}" + end) + + Logger.warning(""" + App installation not found and GITHUB_PAT_ENABLED is false, skipping Github call: + #{function_name}(#{formatted_args}) + """) + end + end + @impl true def get_repository(token, owner, repo), do: client().get_repository(token, owner, repo) diff --git a/lib/algora/integrations/stripe/stripe.ex b/lib/algora/integrations/stripe/stripe.ex index 997623242..29d3fe065 100644 --- a/lib/algora/integrations/stripe/stripe.ex +++ b/lib/algora/integrations/stripe/stripe.ex @@ -32,4 +32,16 @@ defmodule Algora.Stripe do def create(params), do: Algora.Stripe.client(__MODULE__).create(params) end + + defmodule PaymentMethod do + @moduledoc false + + def attach(params), do: Algora.Stripe.client(__MODULE__).attach(params) + end + + defmodule SetupIntent do + @moduledoc false + + def retrieve(id, params), do: Algora.Stripe.client(__MODULE__).retrieve(id, params) + end end diff --git a/lib/algora/payments/jobs/execute_pending_transfers.ex b/lib/algora/payments/jobs/execute_pending_transfers.ex index 2117e4e4d..47311e589 100644 --- a/lib/algora/payments/jobs/execute_pending_transfers.ex +++ b/lib/algora/payments/jobs/execute_pending_transfers.ex @@ -1,4 +1,4 @@ -defmodule Algora.Payments.Jobs.ExecutePendingTransfers do +defmodule Algora.Payments.Jobs.ExecutePendingTransfer do @moduledoc false use Oban.Worker, queue: :transfers, @@ -7,7 +7,7 @@ defmodule Algora.Payments.Jobs.ExecutePendingTransfers do alias Algora.Payments @impl Oban.Worker - def perform(%Oban.Job{args: %{"user_id" => user_id}}) do - Payments.execute_pending_transfers(user_id) + def perform(%Oban.Job{args: %{"credit_id" => credit_id}}) do + Payments.execute_pending_transfer(credit_id) end end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index dfbfc7383..d30d52768 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -8,6 +8,7 @@ defmodule Algora.Payments do alias Algora.MoneyUtils alias Algora.Payments.Account alias Algora.Payments.Customer + alias Algora.Payments.Jobs alias Algora.Payments.PaymentMethod alias Algora.Payments.Transaction alias Algora.Repo @@ -319,80 +320,139 @@ defmodule Algora.Payments do end end - def execute_pending_transfers(user_id) do - pending_amount = get_pending_amount(user_id) + @spec execute_pending_transfer(credit_id :: String.t()) :: + {:ok, Stripe.Transfer.t()} | {:error, :not_found} | {:error, :duplicate_transfer_attempt} + def execute_pending_transfer(credit_id) do + with {:ok, credit} <- Repo.fetch_by(Transaction, id: credit_id, type: :credit, status: :succeeded) do + transfers = + Repo.all( + from(t in Transaction, + where: t.user_id == ^credit.user_id, + where: t.group_id == ^credit.group_id, + where: t.type == :transfer, + where: t.status in [:initialized, :processing, :succeeded] + ) + ) - with {:ok, account} <- Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true), - true <- Money.positive?(pending_amount) do - initialize_and_execute_transfer(user_id, pending_amount, account) - else - _ -> {:ok, nil} + amount_transferred = Enum.reduce(transfers, Money.zero(:USD), fn t, acc -> Money.add!(acc, t.net_amount) end) + + if Money.positive?(amount_transferred) do + Logger.error("Duplicate transfer attempt at transaction #{credit_id}") + {:error, :duplicate_transfer_attempt} + else + initialize_and_execute_transfer(credit) + end end end - defp get_pending_amount(user_id) do - total_credits = - Repo.one( - from(t in Transaction, - where: t.user_id == ^user_id, - where: t.type == :credit, - where: t.status == :succeeded, - select: sum(t.net_amount) - ) - ) || Money.zero(:USD) - - total_transfers = - Repo.one( - from(t in Transaction, - where: t.user_id == ^user_id, - where: t.type == :transfer, - where: t.status == :succeeded or t.status == :processing or t.status == :initialized, - select: sum(t.net_amount) - ) - ) || Money.zero(:USD) + def list_payable_credits(user_id) do + Repo.all( + from(cr in Transaction, + left_join: tr in Transaction, + on: + tr.user_id == cr.user_id and tr.group_id == cr.group_id and tr.type == :transfer and + tr.status in [:initialized, :processing, :succeeded], + where: cr.user_id == ^user_id, + where: cr.type == :credit, + where: cr.status == :succeeded, + where: is_nil(tr.id) + ) + ) + end + + @spec enqueue_pending_transfers(user_id :: String.t()) :: {:ok, nil} | {:error, term()} + def enqueue_pending_transfers(user_id) do + Repo.transact(fn -> + with {:ok, _account} <- fetch_active_account(user_id), + credits = list_payable_credits(user_id), + :ok <- + Enum.reduce_while(credits, :ok, fn credit, :ok -> + case %{credit_id: credit.id} + |> Jobs.ExecutePendingTransfer.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + end) do + {:ok, nil} + else + {:error, reason} -> + Logger.error("Failed to execute pending transfers: #{inspect(reason)}") + {:error, reason} + end + end) + end - Money.sub!(total_credits, total_transfers) + @spec fetch_active_account(user_id :: String.t()) :: {:ok, Account.t()} | {:error, :no_active_account} + def fetch_active_account(user_id) do + case Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true) do + {:ok, account} -> {:ok, account} + {:error, :not_found} -> {:error, :no_active_account} + end end - defp initialize_and_execute_transfer(user_id, pending_amount, account) do - with {:ok, transaction} <- initialize_transfer(user_id, pending_amount), - {:ok, transfer} <- execute_transfer(transaction, account) do - broadcast() - {:ok, transfer} - else - error -> - Logger.error("Failed to execute transfer: #{inspect(error)}") - error + @spec initialize_and_execute_transfer(credit :: Transaction.t()) :: {:ok, Stripe.Transfer.t()} | {:error, term()} + defp initialize_and_execute_transfer(%Transaction{} = credit) do + case fetch_active_account(credit.user_id) do + {:ok, account} -> + with {:ok, transaction} <- initialize_transfer(credit), + {:ok, transfer} <- execute_transfer(transaction, account) do + broadcast() + {:ok, transfer} + else + error -> + Logger.error("Failed to execute transfer: #{inspect(error)}") + error + end + + _ -> + Logger.error("Attempted to execute transfer to inactive account") + {:error, :no_active_account} end end - defp initialize_transfer(user_id, pending_amount) do + defp initialize_transfer(%Transaction{} = credit) do %Transaction{} |> change(%{ id: Nanoid.generate(), provider: "stripe", type: :transfer, status: :initialized, - user_id: user_id, - gross_amount: pending_amount, - net_amount: pending_amount, - total_fee: Money.zero(:USD) + tip_id: credit.tip_id, + bounty_id: credit.bounty_id, + contract_id: credit.contract_id, + claim_id: credit.claim_id, + user_id: credit.user_id, + gross_amount: credit.net_amount, + net_amount: credit.net_amount, + total_fee: Money.zero(:USD), + group_id: credit.group_id }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:tip_id) + |> foreign_key_constraint(:bounty_id) + |> foreign_key_constraint(:contract_id) + |> foreign_key_constraint(:claim_id) |> Repo.insert() end - defp execute_transfer(transaction, account) do - # TODO: set other params + defp execute_transfer(%Transaction{} = transaction, account) do + charge = Repo.get_by(Transaction, type: :credit, status: :succeeded, group_id: transaction.group_id) + + transfer_params = + %{ + amount: MoneyUtils.to_minor_units(transaction.net_amount), + currency: MoneyUtils.to_stripe_currency(transaction.net_amount), + destination: account.provider_id, + metadata: %{"version" => metadata_version()} + } + |> Map.merge(if transaction.group_id, do: %{transfer_group: transaction.group_id}, else: %{}) + |> Map.merge(if charge && charge.provider_id, do: %{source_transaction: charge.provider_id}, else: %{}) + # TODO: provide idempotency key - case Algora.Stripe.Transfer.create(%{ - amount: MoneyUtils.to_minor_units(transaction.net_amount), - currency: MoneyUtils.to_stripe_currency(transaction.net_amount), - destination: account.provider_id, - metadata: %{"version" => metadata_version()} - }) do + case Algora.Stripe.Transfer.create(transfer_params) do {:ok, transfer} -> # it's fine if this fails since we'll receive a webhook transaction diff --git a/lib/algora/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex index 27bbe6ddd..37b994ed1 100644 --- a/lib/algora/workspace/schemas/ticket.ex +++ b/lib/algora/workspace/schemas/ticket.ex @@ -19,6 +19,7 @@ defmodule Algora.Workspace.Ticket do belongs_to :repository, Algora.Workspace.Repository has_many :bounties, Algora.Bounties.Bounty + has_many :tips, Algora.Bounties.Tip has_many :activities, {"ticket_activities", Activity}, foreign_key: :assoc_id diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index b0706f37c..aa2756ba6 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -4,9 +4,9 @@ defmodule AlgoraWeb.Webhooks.StripeController do import Ecto.Changeset import Ecto.Query + alias Algora.Bounties alias Algora.Payments alias Algora.Payments.Customer - alias Algora.Payments.Jobs.ExecutePendingTransfers alias Algora.Payments.Transaction alias Algora.Repo alias Algora.Util @@ -27,9 +27,6 @@ defmodule AlgoraWeb.Webhooks.StripeController do set: [status: :succeeded, succeeded_at: DateTime.utc_now()] ) - # TODO: split into two groups: - # - has active payout account -> execute pending transfers - # - has no active payout account -> notify user to connect payout account jobs_result = from(t in Transaction, where: t.group_id == ^group_id, @@ -37,14 +34,23 @@ defmodule AlgoraWeb.Webhooks.StripeController do where: t.status == :succeeded ) |> Repo.all() - |> Enum.map(fn %{user_id: user_id} -> user_id end) - |> Enum.uniq() - |> Enum.reduce_while(:ok, fn user_id, :ok -> - case %{user_id: user_id} - |> ExecutePendingTransfers.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} + |> Enum.reduce_while(:ok, fn credit, :ok -> + case Payments.fetch_active_account(credit.user_id) do + {:ok, _account} -> + case %{credit_id: credit.id} + |> Payments.Jobs.ExecutePendingTransfer.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, :no_active_account} -> + case %{credit_id: credit.id} + |> Bounties.Jobs.PromptPayoutConnect.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end end end) @@ -70,8 +76,8 @@ defmodule AlgoraWeb.Webhooks.StripeController do data: %{object: %Stripe.Transfer{metadata: %{"version" => @metadata_version}} = transfer} }) do with {:ok, transaction} <- Repo.fetch_by(Transaction, provider: "stripe", provider_id: transfer.id), - {:ok, _transaction} <- maybe_update_transaction(transaction, transfer) do - # TODO: notify user + {:ok, _transaction} <- maybe_update_transaction(transaction, transfer), + {:ok, _job} <- Oban.insert(Bounties.Jobs.NotifyTransfer.new(%{transfer_id: transaction.id})) do Payments.broadcast() {:ok, nil} else @@ -86,9 +92,9 @@ defmodule AlgoraWeb.Webhooks.StripeController do type: "checkout.session.completed", data: %{object: %Stripe.Session{customer: customer_id, mode: "setup", setup_intent: setup_intent_id}} }) do - with {:ok, setup_intent} <- Stripe.SetupIntent.retrieve(setup_intent_id, %{}), + with {:ok, setup_intent} <- Algora.Stripe.SetupIntent.retrieve(setup_intent_id, %{}), pm_id = setup_intent.payment_method, - {:ok, payment_method} <- Stripe.PaymentMethod.attach(%{payment_method: pm_id, customer: customer_id}), + {:ok, payment_method} <- Algora.Stripe.PaymentMethod.attach(%{payment_method: pm_id, customer: customer_id}), {:ok, customer} <- Repo.fetch_by(Customer, provider: "stripe", provider_id: customer_id), {:ok, _} <- Payments.create_payment_method(customer, payment_method) do Payments.broadcast() diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index 5ad41725e..7d8496e01 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -7,6 +7,9 @@ defmodule Algora.BountiesTest do alias Algora.Activities.Notifier alias Algora.Activities.SendEmail + alias Algora.Bounties + alias Algora.Payments.Transaction + alias Bounties.Tip describe "bounties" do test "create" do @@ -35,10 +38,10 @@ defmodule Algora.BountiesTest do amount: amount } - assert {:ok, bounty} = Algora.Bounties.create_bounty(bounty_params, []) + assert {:ok, bounty} = Bounties.create_bounty(bounty_params, []) assert {:ok, claims} = - Algora.Bounties.claim_bounty( + Bounties.claim_bounty( %{ user: recipient, coauthor_provider_logins: [], @@ -53,7 +56,7 @@ defmodule Algora.BountiesTest do claims = Repo.preload(claims, :user) assert {:ok, _bounty} = - Algora.Bounties.reward_bounty( + Bounties.reward_bounty( %{ owner: owner, amount: ~M[4000]usd, @@ -64,7 +67,7 @@ defmodule Algora.BountiesTest do ) assert {:ok, _stripe_session_url} = - Algora.Bounties.create_tip( + Bounties.create_tip( %{ amount: amount, owner: owner, @@ -83,7 +86,7 @@ defmodule Algora.BountiesTest do 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 == Algora.Bounties.Tip + assert activity.assoc.__meta__.schema == Tip assert activity.assoc.creator.id == creator.id assert_enqueued(worker: Notifier, args: %{"activity_id" => bounty.id}) @@ -115,18 +118,99 @@ defmodule Algora.BountiesTest do amount: amount } - Algora.Bounties.create_bounty(bounty_params, []) + Bounties.create_bounty(bounty_params, []) end) - assert Algora.Bounties.list_bounties( + assert Bounties.list_bounties( owner_id: bounty.owner_id, tech_stack: ["elixir"], status: :open ) - # assert Algora.Bounties.fetch_stats(bounty.owner_id) - # assert Algora.Bounties.fetch_stats() - assert Algora.Bounties.PrizePool.list() + # assert Bounties.fetch_stats(bounty.owner_id) + # assert Bounties.fetch_stats() + assert Bounties.PrizePool.list() + end + + test "successfully creates and pays invoice for bounty claim" do + creator = insert!(:user) + owner = insert!(:organization) + customer = insert!(:customer, user: owner) + payment_method = insert!(:payment_method, customer: customer) + recipient = insert!(:user) + installation = insert!(:installation, owner: creator, connected_user: owner) + _identity = insert!(:identity, user: creator, provider_email: creator.email) + repo = insert!(:repository, %{user: owner}) + ticket = insert!(:ticket, %{repository: repo}) + amount = ~M[4000]usd + + ticket_ref = %{ + owner: owner.handle, + repo: repo.name, + number: ticket.number + } + + assert {:ok, bounty} = + Bounties.create_bounty( + %{ + ticket_ref: ticket_ref, + owner: owner, + creator: creator, + amount: amount + }, + installation_id: installation.id + ) + + assert {:ok, [claim]} = + Bounties.claim_bounty( + %{ + user: recipient, + coauthor_provider_logins: [], + target_ticket_ref: ticket_ref, + source_ticket_ref: ticket_ref, + status: :pending, + type: :pull_request + }, + installation_id: installation.id + ) + + claim = Repo.preload(claim, :user) + + assert {:ok, invoice} = + Bounties.create_invoice( + %{owner: owner, amount: amount}, + ticket_ref: ticket_ref, + bounty_id: bounty.id, + claims: [claim] + ) + + assert {:ok, _invoice} = + Algora.Stripe.Invoice.pay(invoice, %{ + payment_method: payment_method.provider_id, + off_session: true + }) + + charge = Repo.one!(from t in Transaction, where: t.type == :charge) + assert Money.equal?(charge.net_amount, amount) + assert charge.status == :initialized + assert charge.user_id == owner.id + + debit = Repo.one!(from t in Transaction, where: t.type == :debit) + assert Money.equal?(debit.net_amount, amount) + assert debit.status == :initialized + assert debit.user_id == owner.id + assert debit.bounty_id == bounty.id + assert debit.claim_id == claim.id + + credit = Repo.one!(from t in Transaction, where: t.type == :credit) + assert Money.equal?(credit.net_amount, amount) + assert credit.status == :initialized + assert credit.user_id == recipient.id + assert credit.bounty_id == bounty.id + assert credit.claim_id == claim.id + + transfer = Repo.one(from t in Transaction, where: t.type == :transfer) + assert is_nil(transfer) end end end diff --git a/test/algora/payments_test.exs b/test/algora/payments_test.exs index 609ef60cb..3f36acb2a 100644 --- a/test/algora/payments_test.exs +++ b/test/algora/payments_test.exs @@ -1,14 +1,16 @@ defmodule Algora.PaymentsTest do use Algora.DataCase + use Oban.Testing, repo: Algora.Repo import ExUnit.CaptureLog alias Algora.Payments alias Algora.Payments.Account + alias Algora.Payments.Jobs.ExecutePendingTransfer alias Algora.Payments.Transaction alias Algora.Repo - describe "execute_pending_transfers/1" do + describe "execute_pending_transfer/1" do setup do user = insert(:user) account = insert(:account, user: user) @@ -17,22 +19,16 @@ defmodule Algora.PaymentsTest do end test "executes transfer when user has positive balance", %{user: user, account: account} do - insert(:transaction, - user: user, - type: :credit, - status: :succeeded, - net_amount: Money.new(1, :USD) - ) - - insert(:transaction, - user: user, - type: :credit, - status: :succeeded, - net_amount: Money.new(2, :USD) - ) - - assert {:ok, transfer} = Payments.execute_pending_transfers(user.id) - assert transfer.amount == 100 + 200 + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) + + assert {:ok, transfer} = Payments.execute_pending_transfer(credit.id) + assert transfer.amount == 100 assert transfer.currency == "usd" assert transfer.destination == account.provider_id @@ -41,71 +37,158 @@ defmodule Algora.PaymentsTest do assert transfer_tx.type == :transfer assert transfer_tx.provider == "stripe" assert transfer_tx.provider_meta["id"] == transfer.id - assert Money.equal?(transfer_tx.net_amount, Money.new(1 + 2, :USD)) - assert Money.equal?(transfer_tx.gross_amount, Money.new(1 + 2, :USD)) + assert Money.equal?(transfer_tx.net_amount, Money.new(1, :USD)) + assert Money.equal?(transfer_tx.gross_amount, Money.new(1, :USD)) assert Money.equal?(transfer_tx.total_fee, Money.new(0, :USD)) + + assert Transaction |> where([t], t.type == :transfer) |> Repo.aggregate(:count) == 1 end test "does nothing when user has positive unconfirmed balance", %{user: user} do - insert(:transaction, - user: user, - type: :credit, - status: :processing, - net_amount: Money.new(1, :USD) - ) - - assert {:ok, nil} = Payments.execute_pending_transfers(user.id) + credit = + insert(:transaction, + user: user, + type: :credit, + status: :processing, + net_amount: Money.new(1, :USD) + ) + + assert {:error, :not_found} = Payments.execute_pending_transfer(credit.id) assert Transaction |> where([t], t.type == :transfer) |> Repo.aggregate(:count) == 0 end - test "does nothing when user has zero balance", %{user: user} do - assert {:ok, nil} = Payments.execute_pending_transfers(user.id) - assert Repo.aggregate(Transaction, :count) == 0 - end - test "does nothing when user has payouts disabled", %{user: user, account: account} do account |> change(payouts_enabled: false) |> Repo.update() - insert(:transaction, - user: user, - type: :credit, - status: :succeeded, - net_amount: Money.new(1, :USD) - ) + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) - assert {:ok, nil} = Payments.execute_pending_transfers(user.id) + {result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit.id) end) + assert {:error, :no_active_account} = result assert Transaction |> where([t], t.type == :transfer) |> Repo.aggregate(:count) == 0 end test "does nothing when user has no stripe account", %{user: user} do Repo.delete_all(Account) - insert(:transaction, - user: user, - type: :credit, - status: :succeeded, - net_amount: Money.new(1, :USD) - ) + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) - assert {:ok, nil} = Payments.execute_pending_transfers(user.id) + {result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit.id) end) + assert {:error, :no_active_account} = result assert Transaction |> where([t], t.type == :transfer) |> Repo.aggregate(:count) == 0 end test "handles failed stripe transfers", %{user: user} do - insert(:transaction, - user: user, - type: :credit, - status: :succeeded, - net_amount: Money.new(1, :USD) - ) + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) Account |> Repo.one!() |> change(%{provider_id: "acct_invalid"}) |> Repo.update!() - {result, _log} = with_log(fn -> Payments.execute_pending_transfers(user.id) end) + {result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit.id) end) assert {:error, %Stripe.Error{code: :invalid_request_error}} = result transfer_tx = Repo.one(from t in Transaction, where: t.type == :transfer) assert transfer_tx.status == :failed end end + + describe "enqueue_pending_transfers/1" do + setup do + user = insert(:user) + account = insert(:account, user: user) + + {:ok, user: user, account: account} + end + + test "enqueues transfer when user has positive balance", %{user: user} do + credit1 = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) + + credit2 = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(2, :USD) + ) + + assert {:ok, nil} = Payments.enqueue_pending_transfers(user.id) + assert_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit1.id}) + assert_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit2.id}) + end + + test "does nothing when user has positive unconfirmed balance", %{user: user} do + credit = + insert(:transaction, + user: user, + type: :credit, + status: :processing, + net_amount: Money.new(1, :USD) + ) + + assert {:ok, nil} = Payments.enqueue_pending_transfers(user.id) + refute_enqueued(worker: ExecutePendingTransfer) + refute_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit.id}) + end + + test "does nothing when user has zero balance", %{user: user} do + assert {:ok, nil} = Payments.enqueue_pending_transfers(user.id) + refute_enqueued(worker: ExecutePendingTransfer) + end + + test "does nothing when user has payouts disabled", %{user: user, account: account} do + account |> change(payouts_enabled: false) |> Repo.update() + + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) + + {result, _log} = with_log(fn -> Payments.enqueue_pending_transfers(user.id) end) + assert {:error, :no_active_account} = result + refute_enqueued(worker: ExecutePendingTransfer) + refute_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit.id}) + end + + test "does nothing when user has no stripe account", %{user: user} do + Repo.delete_all(Account) + + credit = + insert(:transaction, + user: user, + type: :credit, + status: :succeeded, + net_amount: Money.new(1, :USD) + ) + + {result, _log} = with_log(fn -> Payments.enqueue_pending_transfers(user.id) end) + assert {:error, :no_active_account} = result + refute_enqueued(worker: ExecutePendingTransfer) + refute_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit.id}) + end + end end diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs new file mode 100644 index 000000000..a650ed6ab --- /dev/null +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -0,0 +1,161 @@ +defmodule AlgoraWeb.Webhooks.StripeControllerTest do + use AlgoraWeb.ConnCase + use Oban.Testing, repo: Algora.Repo + + import Algora.Factory + import Ecto.Query + + alias Algora.Bounties + alias Algora.Payments + alias Algora.Payments.PaymentMethod + alias Algora.Payments.Transaction + alias Algora.Repo + alias AlgoraWeb.Webhooks.StripeController + + setup do + sender = insert(:user) + recipient = insert(:user) + customer = insert(:customer, user: sender) + metadata = %{"version" => Payments.metadata_version()} + {:ok, customer: customer, metadata: metadata, sender: sender, recipient: recipient} + end + + describe "handle_event/1 for charge.succeeded" do + test "updates transaction status and enqueues PromptPayoutConnect job", %{ + metadata: metadata, + sender: sender, + recipient: recipient + } do + group_id = "#{Algora.Util.random_int()}" + + debit_tx = + insert(:transaction, %{ + type: :debit, + status: :initialized, + group_id: group_id, + user_id: sender.id + }) + + credit_tx = + insert(:transaction, %{ + type: :credit, + status: :initialized, + group_id: group_id, + user_id: recipient.id + }) + + event = %Stripe.Event{ + type: "charge.succeeded", + data: %{ + object: %Stripe.Charge{ + metadata: Map.put(metadata, "group_id", group_id) + } + } + } + + assert {:ok, _} = StripeController.handle_event(event) + + assert Repo.get(Transaction, credit_tx.id).status == :succeeded + assert Repo.get(Transaction, debit_tx.id).status == :succeeded + + assert_enqueued(worker: Bounties.Jobs.PromptPayoutConnect) + end + + test "updates transaction status and enqueues ExecutePendingTransfer for enabled accounts", %{ + metadata: metadata, + sender: sender, + recipient: recipient + } do + _account = insert(:account, %{user_id: recipient.id, payouts_enabled: true}) + group_id = "#{Algora.Util.random_int()}" + + debit_tx = + insert(:transaction, %{ + type: :debit, + status: :initialized, + group_id: group_id, + user_id: sender.id + }) + + credit_tx = + insert(:transaction, %{ + type: :credit, + status: :initialized, + group_id: group_id, + user_id: recipient.id + }) + + event = %Stripe.Event{ + type: "charge.succeeded", + data: %{ + object: %Stripe.Charge{ + metadata: Map.put(metadata, "group_id", group_id) + } + } + } + + assert {:ok, _} = StripeController.handle_event(event) + + assert Repo.get(Transaction, credit_tx.id).status == :succeeded + assert Repo.get(Transaction, debit_tx.id).status == :succeeded + + assert_enqueued(worker: Payments.Jobs.ExecutePendingTransfer, args: %{credit_id: credit_tx.id}) + end + end + + describe "handle_event/1 for transfer.created" do + test "updates associated transaction status", %{metadata: metadata} do + transfer_id = "tr_#{Algora.Util.random_int()}" + + transaction = + insert(:transaction, %{ + provider: "stripe", + provider_id: transfer_id, + type: :transfer, + status: :initialized + }) + + event = %Stripe.Event{ + type: "transfer.created", + data: %{ + object: %Stripe.Transfer{ + id: transfer_id, + metadata: metadata + } + } + } + + assert {:ok, nil} = StripeController.handle_event(event) + + updated_tx = Repo.get(Transaction, transaction.id) + assert updated_tx.status == :succeeded + assert updated_tx.succeeded_at != nil + + assert_enqueued(worker: Bounties.Jobs.NotifyTransfer, args: %{transfer_id: transaction.id}) + end + end + + describe "handle_event/1 for checkout.session.completed" do + test "creates payment method for setup mode", %{customer: customer} do + setup_intent_id = "seti_#{Algora.Util.random_int()}" + + event = %Stripe.Event{ + type: "checkout.session.completed", + data: %{ + object: %Stripe.Session{ + customer: customer.provider_id, + mode: "setup", + setup_intent: setup_intent_id + } + } + } + + assert :ok = StripeController.handle_event(event) + + {:ok, setup_intent} = Algora.Stripe.SetupIntent.retrieve(setup_intent_id, %{}) + + payment_method = Repo.one!(from p in PaymentMethod, where: p.provider_id == ^setup_intent.payment_method) + assert payment_method.customer_id == customer.id + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index d61df9e29..09526bef1 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -141,7 +141,8 @@ defmodule Algora.Factory do def transaction_factory do %Algora.Payments.Transaction{ - id: Nanoid.generate() + id: Nanoid.generate(), + group_id: Nanoid.generate() } end diff --git a/test/support/github_mock.ex b/test/support/github_mock.ex index af3bbeb5a..29e146ccb 100644 --- a/test/support/github_mock.ex +++ b/test/support/github_mock.ex @@ -18,7 +18,12 @@ defmodule Algora.Support.GithubMock do @impl true def get_repository(_access_token, owner, repo) do - {:ok, %{"id" => random_id(), "name" => "repo_#{random_id()}", "html_url" => "https://github.com/#{owner}/#{repo}"}} + {:ok, + %{ + "id" => random_id(), + "name" => repo, + "html_url" => "https://github.com/#{owner}/#{repo}" + }} end @impl true diff --git a/test/support/stripe_mock.ex b/test/support/stripe_mock.ex index 86fe15338..62b886797 100644 --- a/test/support/stripe_mock.ex +++ b/test/support/stripe_mock.ex @@ -57,4 +57,24 @@ defmodule Algora.Support.StripeMock do {:ok, %Stripe.Session{id: "cs_#{Algora.Util.random_int()}", url: "https://example.com/stripe"}} end end + + defmodule PaymentMethod do + @moduledoc false + def attach(%{payment_method: payment_method_id}) do + {:ok, %Stripe.PaymentMethod{id: payment_method_id}} + end + end + + defmodule SetupIntent do + @moduledoc false + def retrieve(id, _params) do + payment_method_id = "pm_#{:erlang.phash2(id)}" + + {:ok, + %Stripe.SetupIntent{ + id: "seti_#{:erlang.phash2(id)}", + payment_method: payment_method_id + }} + end + end end