diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index eb9f714cf..53b3deee2 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -183,13 +183,15 @@ defmodule Algora.Accounts do where: tx.type == :credit, where: tx.status == :succeeded, where: tx.user_id == ^user.id, + join: ltx in assoc(tx, :linked_transaction), left_join: bounty in assoc(tx, :bounty), left_join: tip in assoc(tx, :tip), join: t in Ticket, on: t.id == bounty.ticket_id or t.id == tip.ticket_id, left_join: r in assoc(t, :repository), as: :r, - left_join: ro in assoc(r, :user), + left_join: ro in User, + on: fragment("? = (case when ? is null then ? else ? end)", ro.id, r.user_id, ltx.user_id, r.user_id), # order_by: ^[desc: order_by], order_by: [desc: sum(tx.net_amount)], group_by: [ro.id], diff --git a/lib/algora/activities/schemas/activity.ex b/lib/algora/activities/schemas/activity.ex index fac9d4c22..088fd986f 100644 --- a/lib/algora/activities/schemas/activity.ex +++ b/lib/algora/activities/schemas/activity.ex @@ -10,6 +10,7 @@ defmodule Algora.Activities.Activity do contract_renewed identity_created user_migrated + user_online bounty_posted bounty_repriced claim_submitted diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index dbd3acd4b..62bc65cc8 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -12,6 +12,7 @@ defmodule Algora.Admin do alias Algora.Github alias Algora.Parser alias Algora.Payments + alias Algora.Payments.Transaction alias Algora.Repo alias Algora.Util alias Algora.Workspace @@ -21,6 +22,48 @@ defmodule Algora.Admin do require Logger + def release_payment(tx_id) do + Repo.transact(fn -> + {_, [tx]} = + Repo.update_all(from(t in Transaction, where: t.id == ^tx_id, select: t), + set: [status: :succeeded, succeeded_at: DateTime.utc_now()] + ) + + Repo.update_all(from(b in Bounty, where: b.id == ^tx.bounty_id), set: [status: :paid]) + + activities_result = Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) + + jobs_result = + case Payments.fetch_active_account(tx.user_id) do + {:ok, _account} -> + %{credit_id: tx.id} + |> Payments.Jobs.ExecutePendingTransfer.new() + |> Oban.insert() + + {:error, :no_active_account} -> + Logger.warning("No active account for user #{tx.user_id}") + + %{credit_id: tx.id} + |> Bounties.Jobs.PromptPayoutConnect.new() + |> Oban.insert() + end + + with {:ok, _} <- activities_result, + {:ok, _} <- jobs_result do + Payments.broadcast() + {:ok, nil} + else + {:error, reason} -> + Logger.error("Failed to update transactions: #{inspect(reason)}") + {:error, :failed_to_update_transactions} + + error -> + Logger.error("Failed to update transactions: #{inspect(error)}") + {:error, :failed_to_update_transactions} + end + end) + end + def refresh_bounty(url) do with %{owner: owner, repo: repo, number: number} <- parse_ticket_url(url), {:ok, ticket} <- Workspace.ensure_ticket(token_for(owner), owner, repo, number) do @@ -396,6 +439,8 @@ defmodule Algora.Admin do end def alert(message, severity) do + Logger.info(message) + %{ payload: %{ embeds: [ diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index e9389597d..2af1ccb4a 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -52,7 +52,9 @@ defmodule Algora.Bounties do ticket: Ticket.t(), visibility: Bounty.visibility(), shared_with: [String.t()], - hours_per_week: integer() | nil + hours_per_week: integer() | nil, + hourly_rate: Money.t() | nil, + contract_type: Bounty.contract_type() | nil }) :: {:ok, Bounty.t()} | {:error, atom()} defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do @@ -64,7 +66,9 @@ defmodule Algora.Bounties do creator_id: creator.id, visibility: params[:visibility] || owner.bounty_mode, shared_with: params[:shared_with] || [], - hours_per_week: params[:hours_per_week] + hours_per_week: params[:hours_per_week], + hourly_rate: params[:hourly_rate], + contract_type: params[:contract_type] }) changeset @@ -112,7 +116,9 @@ defmodule Algora.Bounties do command_source: :ticket | :comment, visibility: Bounty.visibility() | nil, shared_with: [String.t()] | nil, - hours_per_week: integer() | nil + hourly_rate: Money.t() | nil, + hours_per_week: integer() | nil, + contract_type: Bounty.contract_type() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -144,7 +150,9 @@ defmodule Algora.Bounties do ticket: ticket, visibility: opts[:visibility], shared_with: shared_with, - hours_per_week: opts[:hours_per_week] + hourly_rate: opts[:hourly_rate], + hours_per_week: opts[:hours_per_week], + contract_type: opts[:contract_type] }) :set -> @@ -195,7 +203,9 @@ defmodule Algora.Bounties do strategy: strategy(), visibility: Bounty.visibility() | nil, shared_with: [String.t()] | nil, - hours_per_week: integer() | nil + hours_per_week: integer() | nil, + hourly_rate: Money.t() | nil, + contract_type: Bounty.contract_type() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -215,7 +225,9 @@ defmodule Algora.Bounties do ticket: ticket, visibility: opts[:visibility], shared_with: shared_with, - hours_per_week: opts[:hours_per_week] + hours_per_week: opts[:hours_per_week], + hourly_rate: opts[:hourly_rate], + contract_type: opts[:contract_type] }), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do broadcast() @@ -769,7 +781,12 @@ defmodule Algora.Bounties do bounty: Bounty.t(), claims: [Claim.t()] }, - opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, recipient: User.t()] + opts :: [ + ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, + recipient: User.t(), + success_url: String.t(), + cancel_url: String.t() + ] ) :: {:ok, String.t()} | {:error, atom()} def reward_bounty(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do @@ -778,7 +795,37 @@ defmodule Algora.Bounties do ticket_ref: opts[:ticket_ref], bounty: bounty, claims: claims, - recipient: opts[:recipient] + recipient: opts[:recipient], + success_url: opts[:success_url], + cancel_url: opts[:cancel_url] + ) + end + + @spec authorize_payment( + %{ + owner: User.t(), + amount: Money.t(), + bounty: Bounty.t(), + claims: [Claim.t()] + }, + opts :: [ + ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, + recipient: User.t(), + success_url: String.t(), + cancel_url: String.t() + ] + ) :: + {:ok, String.t()} | {:error, atom()} + def authorize_payment(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do + create_payment_session( + %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, + ticket_ref: opts[:ticket_ref], + bounty: bounty, + claims: claims, + recipient: opts[:recipient], + capture_method: :manual, + success_url: opts[:success_url], + cancel_url: opts[:cancel_url] ) end @@ -801,7 +848,7 @@ defmodule Algora.Bounties do description = if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}") platform_fee_pct = - if bounty && Date.before?(bounty.inserted_at, ~D[2025-04-16]) do + if bounty && Date.before?(bounty.inserted_at, ~D[2025-04-16]) && is_nil(bounty.contract_type) do Decimal.div(owner.fee_pct_prev, 100) else Decimal.div(owner.fee_pct, 100) @@ -809,45 +856,62 @@ defmodule Algora.Bounties do transaction_fee_pct = Payments.get_transaction_fee_pct() - payouts = - if recipient do + case opts[:bounty] do + %{contract_type: :marketplace} -> [ %LineItem{ amount: amount, - title: "Payment to @#{recipient.provider_login}", - description: description, + title: "Contract payment - @#{recipient.provider_login}", + description: "(includes all platform and payment processing fees)", image: recipient.avatar_url, type: :payout } ] - else - Enum.map(claims, fn claim -> - %LineItem{ - # TODO: ensure shares are normalized - amount: Money.mult!(amount, claim.group_share), - title: "Payment to @#{claim.user.provider_login}", - description: description, - image: claim.user.avatar_url, - type: :payout - } - end) - end - payouts ++ - [ - %LineItem{ - amount: Money.mult!(amount, platform_fee_pct), - title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})", - type: :fee - }, - %LineItem{ - amount: Money.mult!(amount, transaction_fee_pct), - title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})", - type: :fee - } - ] + _ -> + if recipient do + [ + %LineItem{ + amount: amount, + title: "Payment to @#{recipient.provider_login}", + description: description, + image: recipient.avatar_url, + type: :payout + } + ] + else + Enum.map(claims, fn claim -> + %LineItem{ + # TODO: ensure shares are normalized + amount: Money.mult!(amount, claim.group_share), + title: "Payment to @#{claim.user.provider_login}", + description: description, + image: claim.user.avatar_url, + type: :payout + } + end) + end ++ + [ + %LineItem{ + amount: Money.mult!(amount, platform_fee_pct), + title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})", + type: :fee + }, + %LineItem{ + amount: Money.mult!(amount, transaction_fee_pct), + title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})", + type: :fee + } + ] + end end + def calculate_contract_amount(amount), do: Money.mult!(amount, Decimal.new("1.13")) + + def final_contract_amount(:marketplace, amount), do: amount + + def final_contract_amount(:bring_your_own, amount), do: calculate_contract_amount(amount) + @spec create_payment_session( %{owner: User.t(), amount: Money.t(), description: String.t()}, opts :: [ @@ -855,7 +919,10 @@ defmodule Algora.Bounties do tip_id: String.t(), bounty: Bounty.t(), claims: [Claim.t()], - recipient: User.t() + recipient: User.t(), + capture_method: :automatic | :automatic_async | :manual, + success_url: String.t(), + cancel_url: String.t() ] ) :: {:ok, String.t()} | {:error, atom()} @@ -870,6 +937,19 @@ defmodule Algora.Bounties do bounty: opts[:bounty] ) + payment_intent_data = %{ + description: description, + metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} + } + + {payment_intent_data, session_opts} = + if capture_method = opts[:capture_method] do + {Map.put(payment_intent_data, :capture_method, capture_method), + [success_url: opts[:success_url], cancel_url: opts[:cancel_url]]} + else + {payment_intent_data, []} + end + gross_amount = LineItem.gross_amount(line_items) bounty_id = if bounty = opts[:bounty], do: bounty.id @@ -899,10 +979,12 @@ defmodule Algora.Bounties do group_id: tx_group_id }), {:ok, session} <- - Payments.create_stripe_session(owner, Enum.map(line_items, &LineItem.to_stripe/1), %{ - description: description, - metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} - }) do + Payments.create_stripe_session( + owner, + Enum.map(line_items, &LineItem.to_stripe/1), + payment_intent_data, + session_opts + ) do {:ok, session.url} end end) @@ -1012,7 +1094,6 @@ defmodule Algora.Bounties do }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) - |> Algora.Validations.validate_positive(:total_fee) |> foreign_key_constraint(:user_id) |> unique_constraint([:idempotency_key]) |> Repo.insert() diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index c85aadf29..4749b471e 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -4,18 +4,22 @@ defmodule Algora.Bounties.Bounty do alias Algora.Accounts.User alias Algora.Bounties.Bounty + alias Algora.Types.Money @type visibility :: :community | :exclusive | :public + @type contract_type :: :bring_your_own | :marketplace typed_schema "bounties" do - field :amount, Algora.Types.Money + field :amount, Money field :status, Ecto.Enum, values: [:open, :cancelled, :paid] field :number, :integer, default: 0 field :autopay_disabled, :boolean, default: false field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :community + field :contract_type, Ecto.Enum, values: [:bring_your_own, :marketplace] field :shared_with, {:array, :string}, null: false, default: [] field :deadline, :utc_datetime_usec field :hours_per_week, :integer + field :hourly_rate, Money belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User @@ -34,7 +38,17 @@ defmodule Algora.Bounties.Bounty do def changeset(bounty, attrs) do bounty - |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility, :shared_with, :hours_per_week]) + |> cast(attrs, [ + :amount, + :ticket_id, + :owner_id, + :creator_id, + :visibility, + :shared_with, + :hours_per_week, + :hourly_rate, + :contract_type + ]) |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) |> generate_id() |> foreign_key_constraint(:ticket) diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index c0940802c..c1dedd60e 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -188,7 +188,6 @@ defmodule Algora.Contracts do }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) - |> Algora.Validations.validate_positive(:total_fee) |> foreign_key_constraint(:original_contract_id) |> foreign_key_constraint(:contract_id) |> foreign_key_constraint(:timesheet_id) diff --git a/lib/algora/mailer.ex b/lib/algora/mailer.ex index d232646aa..48212d82d 100644 --- a/lib/algora/mailer.ex +++ b/lib/algora/mailer.ex @@ -40,6 +40,9 @@ defmodule Algora.Mailer do
+