From b1d0f98d250ad7cc730845525fe587758d7bcb89 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 28 Jan 2025 12:05:37 +0300 Subject: [PATCH 01/11] in midst of setting up transfer notifs --- lib/algora/payments/payments.ex | 8 +++++++- .../controllers/webhooks/stripe_controller.ex | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index dfbfc7383..1df0df458 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -319,10 +319,11 @@ defmodule Algora.Payments do end end + @spec execute_pending_transfers(user_id :: String.t()) :: {:ok, Stripe.Transfer.t()} | {:error, :not_found} def execute_pending_transfers(user_id) do pending_amount = get_pending_amount(user_id) - with {:ok, account} <- Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true), + with {:ok, account} <- fetch_active_account(user_id), true <- Money.positive?(pending_amount) do initialize_and_execute_transfer(user_id, pending_amount, account) else @@ -330,6 +331,11 @@ defmodule Algora.Payments do end end + @spec fetch_active_account(user_id :: String.t()) :: {:ok, Account.t()} | {:error, :not_found} + def fetch_active_account(user_id) do + Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true) + end + defp get_pending_amount(user_id) do total_credits = Repo.one( diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index b0706f37c..5572dc3a9 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -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, @@ -40,11 +37,18 @@ defmodule AlgoraWeb.Webhooks.StripeController do |> 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} + case Payments.fetch_active_account(user_id) do + {:ok, _account} -> + case %{user_id: user_id} + |> ExecutePendingTransfers.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, :not_found} -> + # TODO: notify user to connect payout account + {:cont, :ok} end end) From 33bcf041998e2840857a94d7e820ebe7e72b8474 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 31 Jan 2025 20:14:12 +0300 Subject: [PATCH 02/11] add prompt payout connect job --- config/config.exs | 1 + .../bounties/jobs/prompt_payout_connect.ex | 52 +++++++++++++++++++ .../controllers/webhooks/stripe_controller.ex | 16 ++++-- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 lib/algora/bounties/jobs/prompt_payout_connect.ex diff --git a/config/config.exs b/config/config.exs index 402ff8b8b..dcb75b2ab 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,6 +34,7 @@ config :algora, Oban, notify_bounty: 1, notify_tip_intent: 1, notify_claim: 1, + prompt_payout_connect: 10, transfers: 1, activity_notifier: 1, activity_mailer: 1 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..c8921801c --- /dev/null +++ b/lib/algora/bounties/jobs/prompt_payout_connect.ex @@ -0,0 +1,52 @@ +defmodule Algora.Bounties.Jobs.PromptPayoutConnect do + @moduledoc false + use Oban.Worker, queue: :prompt_payout_connect + + alias Algora.Github + + require Logger + + # TODO: confirm these urls + defp signup_url, do: "#{AlgoraWeb.Endpoint.url()}" + defp connect_url, do: "#{AlgoraWeb.Endpoint.url()}/user/transactions" + defp body, do: "💵 To receive payouts, [sign up on Algora](#{signup_url()}) and [connect with Stripe](#{connect_url()})." + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => nil}}) do + if Github.pat_enabled() do + Github.create_issue_comment( + Github.pat(), + ticket_ref["owner"], + ticket_ref["repo"], + ticket_ref["number"], + body() + ) + else + Logger.info(""" + Github.create_issue_comment(Github.pat(), "#{ticket_ref["owner"]}", "#{ticket_ref["repo"]}", #{ticket_ref["number"]}, + \"\"\" + #{body()} + \"\"\") + """) + end + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do + ticket_ref = %{ + owner: ticket_ref["owner"], + repo: ticket_ref["repo"], + number: ticket_ref["number"] + } + + with {:ok, token} <- Github.get_installation_token(installation_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_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 5572dc3a9..5e657c623 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -4,6 +4,7 @@ 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 @@ -40,15 +41,24 @@ defmodule AlgoraWeb.Webhooks.StripeController do case Payments.fetch_active_account(user_id) do {:ok, _account} -> case %{user_id: user_id} - |> ExecutePendingTransfers.new() + |> Payments.Jobs.ExecutePendingTransfers.new() |> Oban.insert() do {:ok, _job} -> {:cont, :ok} error -> {:halt, error} end {:error, :not_found} -> - # TODO: notify user to connect payout account - {:cont, :ok} + # TODO: + installation_id = 0 + # TODO: + ticket_ref = %{"owner" => "", "repo" => "", "number" => 0} + + case %{installation_id: installation_id, ticket_ref: ticket_ref} + |> Bounties.Jobs.PromptPayoutConnect.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end end end) From e7e388647f025c1b60fb6066b33ab856513cae26 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 31 Jan 2025 21:19:52 +0300 Subject: [PATCH 03/11] execute separate transfer for each payable credit --- .../jobs/execute_pending_transfers.ex | 6 +- lib/algora/payments/payments.ex | 154 +++++++++----- .../controllers/webhooks/stripe_controller.ex | 14 +- test/algora/payments_test.exs | 189 +++++++++++++----- test/support/factory.ex | 3 +- 5 files changed, 247 insertions(+), 119 deletions(-) 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 1df0df458..11ef84357 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,70 +320,109 @@ defmodule Algora.Payments do end end - @spec execute_pending_transfers(user_id :: String.t()) :: {:ok, Stripe.Transfer.t()} | {:error, :not_found} - 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} <- fetch_active_account(user_id), - 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 - @spec fetch_active_account(user_id :: String.t()) :: {:ok, Account.t()} | {:error, :not_found} - def fetch_active_account(user_id) do - Repo.fetch_by(Account, user_id: user_id, provider: "stripe", payouts_enabled: true) + 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 - 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) + @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) + 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) @@ -390,15 +430,21 @@ defmodule Algora.Payments do |> 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_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 5e657c623..60b08454c 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -7,7 +7,6 @@ defmodule AlgoraWeb.Webhooks.StripeController do 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 @@ -35,21 +34,20 @@ 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 Payments.fetch_active_account(user_id) do + |> Enum.reduce_while(:ok, fn credit, :ok -> + case Payments.fetch_active_account(credit.user_id) do {:ok, _account} -> - case %{user_id: user_id} - |> Payments.Jobs.ExecutePendingTransfers.new() + case %{credit_id: credit.id} + |> Payments.Jobs.ExecutePendingTransfer.new() |> Oban.insert() do {:ok, _job} -> {:cont, :ok} error -> {:halt, error} end - {:error, :not_found} -> + {:error, :no_active_account} -> # TODO: installation_id = 0 + # TODO: ticket_ref = %{"owner" => "", "repo" => "", "number" => 0} 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/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 From 99f9a630f9f78265e31b0a84862e422394e4b709 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Feb 2025 20:56:55 +0300 Subject: [PATCH 04/11] add invoice payment test for bounties --- test/algora/bounties_test.exs | 104 ++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) 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 From 7f8184a6614da6ed985dba9f95b5d30601fb41a8 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 21 Feb 2025 21:00:48 +0300 Subject: [PATCH 05/11] in midst of adding stripe controller tests --- .../webhooks/stripe_controller_test.exs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 test/algora_web/controllers/webhooks/stripe_controller_test.exs 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..837dff954 --- /dev/null +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -0,0 +1,117 @@ +defmodule AlgoraWeb.Webhooks.StripeControllerTest do + use AlgoraWeb.ConnCase + use Oban.Testing, repo: Algora.Repo + + import Algora.Factory + import Ecto.Query + + alias Algora.Payments + alias Algora.Payments.PaymentMethod + alias Algora.Payments.Transaction + alias Algora.Repo + alias AlgoraWeb.Webhooks.StripeController + + setup do + # Insert a test customer + user = insert(:user) + customer = insert(:customer, user: user) + + # Common metadata for stripe events + metadata = %{"version" => Payments.metadata_version()} + + {:ok, customer: customer, metadata: metadata} + end + + describe "handle_event/1 for charge.succeeded" do + test "updates transaction status and creates jobs for credits", %{metadata: metadata} do + group_id = Ecto.UUID.generate() + + # Create test transactions in the group + credit_tx = + insert(:transaction, %{ + type: :credit, + status: :pending, + group_id: group_id + }) + + debit_tx = + insert(:transaction, %{ + type: :debit, + status: :pending, + group_id: group_id + }) + + # Create stripe event + event = %Stripe.Event{ + type: "charge.succeeded", + data: %{ + object: %Stripe.Charge{ + metadata: Map.put(metadata, "group_id", group_id) + } + } + } + + assert {:ok, nil} = StripeController.handle_event(event) + + # Assert transactions were updated + assert Repo.get(Transaction, credit_tx.id).status == :succeeded + assert Repo.get(Transaction, debit_tx.id).status == :succeeded + + # Assert jobs were created + 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_#{Ecto.UUID.generate()}" + + transaction = + insert(:transaction, %{ + provider: "stripe", + provider_id: transfer_id, + status: :pending + }) + + 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 + 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_#{Ecto.UUID.generate()}" + payment_method_id = "pm_#{Ecto.UUID.generate()}" + + 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) + + # Assert payment method was created + payment_method = Repo.one!(from p in PaymentMethod, where: p.provider_id == ^payment_method_id) + assert payment_method.customer_id == customer.id + end + end +end From a30ded25fbb05bb98177adc2972d8c1926897a71 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 22 Feb 2025 21:04:34 +0300 Subject: [PATCH 06/11] make tests pass --- lib/algora/integrations/stripe/stripe.ex | 12 +++ .../controllers/webhooks/stripe_controller.ex | 4 +- .../webhooks/stripe_controller_test.exs | 90 ++++++++++++++----- test/support/stripe_mock.ex | 20 +++++ 4 files changed, 100 insertions(+), 26 deletions(-) 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_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 60b08454c..4573927c4 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -98,9 +98,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_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index 837dff954..a2e87477e 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -5,6 +5,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do import Algora.Factory import Ecto.Query + alias Algora.Bounties alias Algora.Payments alias Algora.Payments.PaymentMethod alias Algora.Payments.Transaction @@ -12,36 +13,78 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do alias AlgoraWeb.Webhooks.StripeController setup do - # Insert a test customer - user = insert(:user) - customer = insert(:customer, user: user) - - # Common metadata for stripe events + sender = insert(:user) + recipient = insert(:user) + customer = insert(:customer, user: sender) metadata = %{"version" => Payments.metadata_version()} - - {:ok, customer: customer, metadata: metadata} + {:ok, customer: customer, metadata: metadata, sender: sender, recipient: recipient} end describe "handle_event/1 for charge.succeeded" do - test "updates transaction status and creates jobs for credits", %{metadata: metadata} do - group_id = Ecto.UUID.generate() + 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 + }) - # Create test transactions in the group credit_tx = insert(:transaction, %{ type: :credit, - status: :pending, - group_id: group_id + 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: :pending, - group_id: group_id + 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 }) - # Create stripe event event = %Stripe.Event{ type: "charge.succeeded", data: %{ @@ -51,26 +94,25 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do } } - assert {:ok, nil} = StripeController.handle_event(event) + assert {:ok, _} = StripeController.handle_event(event) - # Assert transactions were updated assert Repo.get(Transaction, credit_tx.id).status == :succeeded assert Repo.get(Transaction, debit_tx.id).status == :succeeded - # Assert jobs were created 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_#{Ecto.UUID.generate()}" + transfer_id = "tr_#{Algora.Util.random_int()}" transaction = insert(:transaction, %{ provider: "stripe", provider_id: transfer_id, - status: :pending + type: :transfer, + status: :initialized }) event = %Stripe.Event{ @@ -93,8 +135,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do describe "handle_event/1 for checkout.session.completed" do test "creates payment method for setup mode", %{customer: customer} do - setup_intent_id = "seti_#{Ecto.UUID.generate()}" - payment_method_id = "pm_#{Ecto.UUID.generate()}" + setup_intent_id = "seti_#{Algora.Util.random_int()}" event = %Stripe.Event{ type: "checkout.session.completed", @@ -109,8 +150,9 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do assert :ok = StripeController.handle_event(event) - # Assert payment method was created - payment_method = Repo.one!(from p in PaymentMethod, where: p.provider_id == ^payment_method_id) + {: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 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 From 3097633bf36eac9384c667a9aeb9e2368b3fdfd6 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 23 Feb 2025 22:51:21 +0300 Subject: [PATCH 07/11] add job to notify transfer --- .iex.exs | 1 + config/config.exs | 3 +- lib/algora/bounties/jobs/notify_transfer.ex | 74 +++++++++++++++++++ lib/algora/workspace/schemas/ticket.ex | 1 + .../controllers/webhooks/stripe_controller.ex | 4 +- .../webhooks/stripe_controller_test.exs | 2 + 6 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 lib/algora/bounties/jobs/notify_transfer.ex 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 dcb75b2ab..bb039d067 100644 --- a/config/config.exs +++ b/config/config.exs @@ -37,7 +37,8 @@ config :algora, Oban, prompt_payout_connect: 10, transfers: 1, activity_notifier: 1, - activity_mailer: 1 + activity_mailer: 1, + notify_transfer: 100 ] # Configures the mailer diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex new file mode 100644 index 000000000..226865875 --- /dev/null +++ b/lib/algora/bounties/jobs/notify_transfer.ex @@ -0,0 +1,74 @@ +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.Ticket + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"transaction_id" => transaction_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 == ^transaction_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 == ^transaction_id, + where: tx.type == :transfer, + select_merge: %{user: user} + ) do + installation = Repo.get(Installation, connected_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 + if Github.pat_enabled() do + Github.create_issue_comment( + Github.pat(), + ticket_ref["owner"], + ticket_ref["repo"], + ticket_ref["number"], + body + ) + else + Logger.info(""" + Github.create_issue_comment(Github.pat(), "#{ticket_ref["owner"]}", "#{ticket_ref["repo"]}", #{ticket_ref["number"]}, + \"\"\" + #{body} + \"\"\") + """) + end + end + + defp do_perform(ticket_ref, body, installation) do + {:ok, token} = Github.get_installation_token(installation.id) + Github.create_issue_comment(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], body) + end +end 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 4573927c4..8b33ea9d1 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -82,8 +82,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(%{transaction_id: transaction.id})) do Payments.broadcast() {:ok, nil} else diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index a2e87477e..20a74ec34 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -130,6 +130,8 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do 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: %{transaction_id: transaction.id}) end end From dd004d63c3ecd122b396ebf185c19c91034f6baa Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 23 Feb 2025 23:03:23 +0300 Subject: [PATCH 08/11] move boilerplate to internal module --- lib/algora/bounties/jobs/notify_transfer.ex | 22 ++++++--------------- lib/algora/integrations/github/github.ex | 22 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex index 226865875..80c6cf1b4 100644 --- a/lib/algora/bounties/jobs/notify_transfer.ex +++ b/lib/algora/bounties/jobs/notify_transfer.ex @@ -49,22 +49,12 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do end defp do_perform(ticket_ref, body, nil) do - if Github.pat_enabled() do - Github.create_issue_comment( - Github.pat(), - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body - ) - else - Logger.info(""" - Github.create_issue_comment(Github.pat(), "#{ticket_ref["owner"]}", "#{ticket_ref["repo"]}", #{ticket_ref["number"]}, - \"\"\" - #{body} - \"\"\") - """) - end + 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 diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index dc19966ce..67d1e9554 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,26 @@ 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, name} = Function.info(function, :name) + function_name = "#{module}.#{name}" + + 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) From 51f983d2cb150dc644217e0ecebab50b478cfb4d Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 23 Feb 2025 23:09:12 +0300 Subject: [PATCH 09/11] make tests pass --- test/support/github_mock.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From dafe632622d23952e7c4bc1dacd57f5105982b1e Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Feb 2025 00:47:32 +0300 Subject: [PATCH 10/11] fix miscellanea --- lib/algora/bounties/jobs/notify_transfer.ex | 14 ++++++++------ lib/algora/integrations/github/github.ex | 5 +++-- lib/algora/payments/payments.ex | 8 ++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex index 80c6cf1b4..4244cbe6e 100644 --- a/lib/algora/bounties/jobs/notify_transfer.ex +++ b/lib/algora/bounties/jobs/notify_transfer.ex @@ -8,6 +8,7 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do alias Algora.Github alias Algora.Payments.Transaction alias Algora.Repo + alias Algora.Workspace.Installation alias Algora.Workspace.Ticket require Logger @@ -41,7 +42,7 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do where: tx.type == :transfer, select_merge: %{user: user} ) do - installation = Repo.get(Installation, connected_user_id: ticket.repository.user.id) + 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) @@ -50,15 +51,16 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do 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"], + ticket_ref.owner, + ticket_ref.repo, + ticket_ref.number, body ]) end defp do_perform(ticket_ref, body, installation) do - {:ok, token} = Github.get_installation_token(installation.id) - Github.create_issue_comment(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], body) + 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 67d1e9554..2a0480efc 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -49,8 +49,9 @@ defmodule Algora.Github do if pat_enabled() do apply(function, [pat() | args]) else - {module, name} = Function.info(function, :name) - function_name = "#{module}.#{name}" + {_, 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 diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 11ef84357..d30d52768 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -418,6 +418,10 @@ defmodule Algora.Payments do provider: "stripe", type: :transfer, status: :initialized, + 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, @@ -427,6 +431,10 @@ defmodule Algora.Payments do |> 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 From 91f9452b383b91d04f8f4ba1eac8fd828f94361f Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 24 Feb 2025 01:06:53 +0300 Subject: [PATCH 11/11] implement prompt connect payout account --- config/config.exs | 6 +- lib/algora/bounties/jobs/notify_transfer.ex | 7 +- .../bounties/jobs/prompt_payout_connect.ex | 104 ++++++++++++------ .../controllers/webhooks/stripe_controller.ex | 10 +- .../webhooks/stripe_controller_test.exs | 2 +- 5 files changed, 77 insertions(+), 52 deletions(-) diff --git a/config/config.exs b/config/config.exs index bb039d067..ae65e6fce 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,11 +34,11 @@ config :algora, Oban, notify_bounty: 1, notify_tip_intent: 1, notify_claim: 1, - prompt_payout_connect: 10, + notify_transfer: 100, + prompt_payout_connect: 100, transfers: 1, activity_notifier: 1, - activity_mailer: 1, - notify_transfer: 100 + activity_mailer: 1 ] # Configures the mailer diff --git a/lib/algora/bounties/jobs/notify_transfer.ex b/lib/algora/bounties/jobs/notify_transfer.ex index 4244cbe6e..20f7913ed 100644 --- a/lib/algora/bounties/jobs/notify_transfer.ex +++ b/lib/algora/bounties/jobs/notify_transfer.ex @@ -14,7 +14,7 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do require Logger @impl Oban.Worker - def perform(%Oban.Job{args: %{"transaction_id" => transaction_id}}) do + def perform(%Oban.Job{args: %{"transafer_id" => transafer_id}}) do with {:ok, ticket} <- Repo.fetch_one( from t in Ticket, @@ -24,7 +24,7 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do 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 == ^transaction_id, + where: tx.id == ^transafer_id, select_merge: %{ repository: %{repo | user: user} } @@ -38,8 +38,7 @@ defmodule Algora.Bounties.Jobs.NotifyTransfer do Repo.fetch_one( from tx in Transaction, join: user in assoc(tx, :user), - where: tx.id == ^transaction_id, - where: tx.type == :transfer, + where: tx.id == ^transafer_id, select_merge: %{user: user} ) do installation = Repo.get_by(Installation, provider_user_id: ticket.repository.user.id) diff --git a/lib/algora/bounties/jobs/prompt_payout_connect.ex b/lib/algora/bounties/jobs/prompt_payout_connect.ex index c8921801c..3354d8506 100644 --- a/lib/algora/bounties/jobs/prompt_payout_connect.ex +++ b/lib/algora/bounties/jobs/prompt_payout_connect.ex @@ -2,51 +2,83 @@ 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 these urls - defp signup_url, do: "#{AlgoraWeb.Endpoint.url()}" - defp connect_url, do: "#{AlgoraWeb.Endpoint.url()}/user/transactions" - defp body, do: "💵 To receive payouts, [sign up on Algora](#{signup_url()}) and [connect with Stripe](#{connect_url()})." + # TODO: confirm url + @onboarding_url "https://console.algora.io/solve" @impl Oban.Worker - def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => nil}}) do - if Github.pat_enabled() do - Github.create_issue_comment( - Github.pat(), - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body() - ) - else - Logger.info(""" - Github.create_issue_comment(Github.pat(), "#{ticket_ref["owner"]}", "#{ticket_ref["repo"]}", #{ticket_ref["number"]}, - \"\"\" - #{body()} - \"\"\") - """) + 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 - @impl Oban.Worker - def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do - ticket_ref = %{ - owner: ticket_ref["owner"], - repo: ticket_ref["repo"], - number: ticket_ref["number"] - } - - with {:ok, token} <- Github.get_installation_token(installation_id) do - Github.create_issue_comment( - token, - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body() - ) + 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_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 8b33ea9d1..aa2756ba6 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -45,13 +45,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do end {:error, :no_active_account} -> - # TODO: - installation_id = 0 - - # TODO: - ticket_ref = %{"owner" => "", "repo" => "", "number" => 0} - - case %{installation_id: installation_id, ticket_ref: ticket_ref} + case %{credit_id: credit.id} |> Bounties.Jobs.PromptPayoutConnect.new() |> Oban.insert() do {:ok, _job} -> {:cont, :ok} @@ -83,7 +77,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do }) do with {:ok, transaction} <- Repo.fetch_by(Transaction, provider: "stripe", provider_id: transfer.id), {:ok, _transaction} <- maybe_update_transaction(transaction, transfer), - {:ok, _job} <- Oban.insert(Bounties.Jobs.NotifyTransfer.new(%{transaction_id: transaction.id})) do + {:ok, _job} <- Oban.insert(Bounties.Jobs.NotifyTransfer.new(%{transfer_id: transaction.id})) do Payments.broadcast() {:ok, nil} else diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index 20a74ec34..a650ed6ab 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -131,7 +131,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do assert updated_tx.status == :succeeded assert updated_tx.succeeded_at != nil - assert_enqueued(worker: Bounties.Jobs.NotifyTransfer, args: %{transaction_id: transaction.id}) + assert_enqueued(worker: Bounties.Jobs.NotifyTransfer, args: %{transfer_id: transaction.id}) end end