diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index e69b680da..3cc78049c 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -429,8 +429,12 @@ defmodule Algora.Bounties do @spec notify_bounty(%{owner: User.t(), bounty: Bounty.t()}, opts :: []) :: {:ok, nil} | {:error, atom()} - def notify_bounty(%{owner: _owner, bounty: bounty}, _opts) do - Algora.Admin.alert("Notify bounty: #{inspect(bounty)}", :error) + def notify_bounty(%{owner: owner, bounty: bounty}, _opts) do + Algora.Admin.alert( + "New contract offer: #{AlgoraWeb.Endpoint.url()}/#{owner.handle}/contracts/#{bounty.id}", + :critical + ) + {:ok, nil} end @@ -906,11 +910,9 @@ defmodule Algora.Bounties do 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) + def final_contract_amount(:bring_your_own, amount), do: Money.mult!(amount, Decimal.new("1.13")) @spec create_payment_session( %{owner: User.t(), amount: Money.t(), description: String.t()}, diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index a7a27bec3..059ca1ecc 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -9,6 +9,7 @@ defmodule Algora.Payments do alias Algora.Bounties alias Algora.Bounties.Bounty alias Algora.Bounties.Claim + alias Algora.Bounties.Jobs.PromptPayoutConnect alias Algora.Bounties.Tip alias Algora.Jobs.JobPosting alias Algora.MoneyUtils @@ -737,7 +738,7 @@ defmodule Algora.Payments do {:error, :no_active_account} -> case %{credit_id: credit.id} - |> Bounties.Jobs.PromptPayoutConnect.new() + |> PromptPayoutConnect.new() |> Oban.insert() do {:ok, _job} -> {:cont, :ok} error -> {:halt, error} @@ -761,4 +762,81 @@ defmodule Algora.Payments do end end) end + + def process_release( + %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, + group_id, + amount, + recipient + ) do + Repo.transact(fn -> + tx = Repo.get_by(Transaction, group_id: group_id, type: :charge, status: :succeeded) + + user = Repo.get_by(User, id: tx.user_id) + bounty = Repo.get_by(Bounty, id: tx.bounty_id) + + Algora.Admin.alert( + "Release #{amount} escrow to #{recipient.handle} for #{AlgoraWeb.Endpoint.url()}/#{user.handle}/contracts/#{bounty.id}", + :critical + ) + + debit_id = Nanoid.generate() + credit_id = Nanoid.generate() + + with {:ok, debit0} <- Repo.fetch_by(Transaction, group_id: group_id, type: :debit, status: :requires_release), + {:ok, _} <- + debit0 + |> change(%{ + net_amount: Money.sub!(debit0.net_amount, amount), + gross_amount: Money.sub!(debit0.gross_amount, amount) + }) + |> Repo.update(), + {:ok, credit0} <- Repo.fetch_by(Transaction, group_id: group_id, type: :credit, status: :requires_release), + {:ok, _} <- + credit0 + |> change(%{ + net_amount: Money.add!(credit0.net_amount, amount), + gross_amount: Money.add!(credit0.gross_amount, amount) + }) + |> Repo.update(), + {:ok, _debit} <- + Repo.insert(%Transaction{ + id: debit_id, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id, + type: :debit, + status: :succeeded, + succeeded_at: DateTime.utc_now(), + bounty_id: tx.bounty_id, + user_id: tx.user_id, + gross_amount: amount, + net_amount: amount, + total_fee: Money.zero(:USD), + linked_transaction_id: credit_id, + group_id: group_id + }), + {:ok, _credit} <- + Repo.insert(%Transaction{ + id: credit_id, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id, + type: :credit, + status: :initialized, + succeeded_at: DateTime.utc_now(), + bounty_id: tx.bounty_id, + user_id: recipient.id, + gross_amount: amount, + net_amount: amount, + total_fee: Money.zero(:USD), + linked_transaction_id: debit_id, + group_id: group_id + }) do + {:ok, nil} + end + end) + end end diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 54b9b164e..239d16290 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -127,6 +127,7 @@ defmodule Algora.Settings do projects = Accounts.list_contributed_projects(user, limit: 2) avatar_url = profile["avatar_url"] || user.avatar_url hourly_rate = match["hourly_rate"] || profile["hourly_rate"] + hours_per_week = match["hours_per_week"] || profile["hours_per_week"] || user.hours_per_week [ %{ @@ -134,7 +135,8 @@ defmodule Algora.Settings do projects: projects, badge_variant: match["badge_variant"], badge_text: match["badge_text"], - hourly_rate: if(hourly_rate, do: Money.new(:USD, hourly_rate, no_fraction_if_integer: true)) + hourly_rate: if(hourly_rate, do: Money.new(:USD, hourly_rate, no_fraction_if_integer: true)), + hours_per_week: hours_per_week } ] else diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index dd4aa8daf..cb04d5d71 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -225,9 +225,7 @@ defmodule AlgoraWeb.Forms.ContractForm do
- {Money.to_string!( - Bounties.calculate_contract_amount(get_change(@form.source, :amount)) - )} + {Money.to_string!(get_change(@form.source, :amount))}
diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index c38627bd6..e7952e5c9 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -12,6 +12,7 @@ defmodule AlgoraWeb.ContractLive do alias Algora.Chat alias Algora.Organizations.Member alias Algora.Payments + alias Algora.Payments.Transaction alias Algora.Repo alias Algora.Types.USD alias Algora.Util @@ -42,6 +43,27 @@ defmodule AlgoraWeb.ContractLive do end end + defmodule ReleaseBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :amount, USD + field :hours, :decimal + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:amount, :hours]) + |> validate_required([:amount]) + |> validate_number(:hours, greater_than: 0) + |> Algora.Validations.validate_money_positive(:amount) + end + end + @impl true def mount(%{"id" => bounty_id}, _session, socket) do bounty = @@ -111,6 +133,9 @@ defmodule AlgoraWeb.ContractLive do reward_changeset = RewardBountyForm.changeset(%RewardBountyForm{}, %{amount: bounty.amount, tip_percentage: 0}) + release_changeset = + ReleaseBountyForm.changeset(%ReleaseBountyForm{}, %{amount: bounty.amount, hours: bounty.hours_per_week}) + {:ok, thread} = Chat.get_or_create_bounty_thread(bounty) messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) participants = thread.id |> Chat.list_participants() |> Repo.preload(:user) @@ -139,12 +164,15 @@ defmodule AlgoraWeb.ContractLive do |> assign(:ticket_body_html, ticket_body_html) |> assign(:show_reward_modal, false) |> assign(:show_authorize_modal, false) + |> assign(:show_release_modal, false) |> assign(:selected_context, nil) + |> assign(:tx_id, nil) |> assign(:line_items, []) |> assign(:thread, thread) |> assign(:messages, messages) |> assign(:participants, participants) |> assign(:reward_form, to_form(reward_changeset)) + |> assign(:release_form, to_form(release_changeset)) |> assign_contractor(bounty.shared_with) |> assign_transactions() |> assign_line_items(reward_changeset)} @@ -206,6 +234,27 @@ defmodule AlgoraWeb.ContractLive do {:noreply, assign(socket, :show_authorize_modal, true)} end + @impl true + def handle_event("release", %{"tx_id" => tx_id}, socket) do + tx = Repo.get(Transaction, tx_id) + + release_changeset = + ReleaseBountyForm.changeset(%ReleaseBountyForm{}, %{ + amount: tx.net_amount, + hours: + tx.net_amount + |> Money.to_decimal() + |> Decimal.mult(socket.assigns.bounty.hours_per_week) + |> Decimal.div(Money.to_decimal(socket.assigns.bounty.amount)) + }) + + {:noreply, + socket + |> assign(:release_form, to_form(release_changeset)) + |> assign(:show_release_modal, true) + |> assign(:tx_id, tx_id)} + end + @impl true def handle_event("close_drawer", _params, socket) do {:noreply, close_drawers(socket)} @@ -221,6 +270,65 @@ defmodule AlgoraWeb.ContractLive do |> assign_line_items(changeset)} end + @impl true + def handle_event("validate_hours", %{"release_bounty_form" => %{"hours" => hours}}, socket) do + hours = + Decimal.new( + case hours do + "" -> "0" + hours -> hours + end + ) + + changeset = + Ecto.Changeset.change(socket.assigns.release_form.source, %{ + hours: hours, + amount: Money.mult!(socket.assigns.bounty.amount, Decimal.div(hours, socket.assigns.bounty.hours_per_week)) + }) + + {:noreply, + socket + |> assign(:release_form, to_form(changeset)) + |> assign_line_items(changeset)} + end + + @impl true + def handle_event("validate_amount", %{"release_bounty_form" => %{"amount" => amount}}, socket) do + amount = + Money.new( + :USD, + case amount do + "" -> "0" + amount -> amount + end + ) + + changeset = + Ecto.Changeset.change(socket.assigns.release_form.source, %{ + amount: amount, + hours: + amount + |> Money.to_decimal() + |> Decimal.mult(socket.assigns.bounty.hours_per_week) + |> Decimal.div(Money.to_decimal(socket.assigns.bounty.amount)) + }) + + {:noreply, + socket + |> assign(:release_form, to_form(changeset)) + |> assign_line_items(changeset)} + end + + @impl true + def handle_event("validate_release", %{"release_bounty_form" => params}, socket) do + changeset = ReleaseBountyForm.changeset(%ReleaseBountyForm{}, params) + + {:noreply, + socket + |> assign(:release_form, to_form(changeset)) + |> assign_line_items(changeset)} + end + @impl true def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params) @@ -254,19 +362,31 @@ defmodule AlgoraWeb.ContractLive do end @impl true - def handle_event("release_funds", %{"tx_id" => tx_id}, socket) do - with tx when not is_nil(tx) <- Enum.find(socket.assigns.transactions, &(&1.id == tx_id)), - {:ok, charge} <- Algora.PSP.Charge.retrieve(tx.provider_charge_id), - {:ok, _} <- Algora.Payments.process_charge("charge.succeeded", charge, tx.group_id) do - {:noreply, socket} - else - {:error, reason} -> - Logger.error("Failed to release funds: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Something went wrong")} + def handle_event("release_funds", %{"release_bounty_form" => params}, socket) do + changeset = ReleaseBountyForm.changeset(%ReleaseBountyForm{}, params) - _ -> - Logger.error("Failed to release funds: transaction not found") - {:noreply, put_flash(socket, :error, "Something went wrong")} + case apply_action(changeset, :save) do + {:ok, data} -> + with tx when not is_nil(tx) <- Enum.find(socket.assigns.transactions, &(&1.id == socket.assigns.tx_id)), + {:ok, charge} <- Algora.PSP.Charge.retrieve(tx.provider_charge_id), + {:ok, _} <- Algora.Payments.process_release(charge, tx.group_id, data.amount, socket.assigns.contractor) do + {:noreply, + socket + |> assign(:show_release_modal, false) + |> put_flash(:info, "Funds released!") + |> assign_transactions()} + else + {:error, reason} -> + Logger.error("Failed to release funds: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + + _ -> + Logger.error("Failed to release funds: transaction not found") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :release_form, to_form(changeset))} end end @@ -356,7 +476,7 @@ defmodule AlgoraWeb.ContractLive do <% end %> <%= if @can_create_bounty && @transactions == [] do %> - <.button phx-click="reward"> + <.button phx-click="reward" variant="secondary"> Make payment <% end %> @@ -484,11 +604,10 @@ defmodule AlgoraWeb.ContractLive do transaction.status == :requires_release } size="sm" - phx-click="release_funds" - phx-disable-with="Releasing..." + phx-click="release" phx-value-tx_id={transaction.id} > - Release funds + Partial release @@ -616,6 +735,93 @@ defmodule AlgoraWeb.ContractLive do + <.drawer :if={@current_user} show={@show_release_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Release funds + <.drawer_description> + {@contractor.name} will be paid once you release the funds. + + + <.drawer_content class="mt-4"> + <.form for={@release_form} phx-change="validate_release" phx-submit="release_funds"> +
+
+ <.card> + <.card_header> + <.card_title>Release Details + + <.card_content class="pt-0"> +
+ <.input + label="Hours" + icon="tabler-clock" + field={@release_form[:hours]} + phx-change="validate_hours" + /> + <.input + label="Amount" + icon="tabler-currency-dollar" + field={@release_form[:amount]} + phx-change="validate_amount" + /> +
+ + + <.card> + <.card_header> + <.card_title>Payment Summary + + <.card_content class="pt-0"> +
+ <%= for line_item <- @line_items do %> +
+
+ <%= if line_item.image do %> + <.avatar> + <.avatar_image src={line_item.image} /> + <.avatar_fallback> + {Util.initials(line_item.title)} + + + <% else %> +
+ <% end %> +
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)} +
+
+ <% end %> +
+
+
+
+
Total due
+
+
+ {LineItem.gross_amount(@line_items)} +
+
+
+ + +
+
+ <.button variant="secondary" phx-click="close_drawer" type="button"> + Cancel + + <.button type="submit"> + Release funds <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + <.drawer :if={@current_user} show={@show_authorize_modal} on_cancel="close_drawer"> <.drawer_header> <.drawer_title>Authorize payment @@ -840,6 +1046,7 @@ defmodule AlgoraWeb.ContractLive do socket |> assign(:show_reward_modal, false) |> assign(:show_authorize_modal, false) + |> assign(:show_release_modal, false) end defp assign_contractor(socket, shared_with) do @@ -859,12 +1066,24 @@ defmodule AlgoraWeb.ContractLive do defp assign_transactions(socket) do transactions = [ - user_id: socket.assigns.bounty.owner.id, status: [:succeeded, :requires_capture, :requires_release], bounty_id: socket.assigns.bounty.id ] |> Payments.list_transactions() - |> Enum.filter(&(&1.type == :charge or &1.status in [:succeeded, :requires_release])) + |> Enum.filter(fn tx -> Money.positive?(tx.net_amount) end) + |> Enum.filter(fn tx -> + cond do + socket.assigns.can_create_bounty -> + tx.type == :charge or + (tx.type == :debit and tx.status in [:succeeded, :requires_release]) + + socket.assigns.current_user.id == socket.assigns.contractor.id -> + tx.type == :credit and tx.status == :succeeded + + true -> + false + end + end) balance = calculate_balance(transactions) volume = calculate_volume(transactions) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 6a31081a9..2f1622ec9 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -82,6 +82,12 @@ defmodule AlgoraWeb.Org.DashboardLive do matches = Algora.Settings.get_org_matches(previewed_user) + contributions = + matches + |> Enum.map(& &1.user.id) + |> Algora.Workspace.list_user_contributions() + |> Enum.group_by(& &1.user.id) + admins_last_active = Algora.Admin.admins_last_active() developers = @@ -106,6 +112,7 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign(:contributors, contributors) |> assign(:previewed_user, previewed_user) |> assign(:matches, matches) + |> assign(:contributions, contributions) |> assign(:developers, developers) |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) @@ -390,7 +397,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
  • <.icon name="tabler-circle-number-3 mr-2" class="size-6 text-success-400 shrink-0" /> - Release/withhold escrow end of week + Release/withhold escrow as you go
  • @@ -398,6 +405,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.match_card match={match} contract_for_user={contract_for_user(@contracts, match.user)} + contributions={@contributions[match.user.id]} current_org={@current_org} /> <% end %> @@ -674,8 +682,7 @@ defmodule AlgoraWeb.Org.DashboardLive do developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) match = Enum.find(socket.assigns.matches, &(&1.user.id == user_id)) hourly_rate = match[:hourly_rate] - - hours_per_week = developer.hours_per_week || 30 + hours_per_week = match[:hours_per_week] || developer.hours_per_week || 30 {:noreply, socket @@ -691,8 +698,8 @@ defmodule AlgoraWeb.Org.DashboardLive do hourly_rate: hourly_rate, contractor_handle: developer.provider_login, hours_per_week: hours_per_week, - title: "#{socket.assigns.current_org.name} OSS Development", - description: "Open source contribution to #{socket.assigns.current_org.name} for a week" + title: "#{socket.assigns.current_org.name} Development", + description: "Contribution to #{socket.assigns.current_org.name} for #{hours_per_week} hours" }) |> to_form() )} @@ -1330,7 +1337,7 @@ defmodule AlgoraWeb.Org.DashboardLive do defp match_card(assigns) do ~H""" -
    +
    @@ -1405,7 +1412,54 @@ defmodule AlgoraWeb.Org.DashboardLive do )}
    -
    +
    + <%= for {owner, contributions} <- aggregate_contributions(@contributions) |> Enum.take(3) do %> + <.link + href={"https://github.com/#{owner.provider_login}/#{List.first(contributions).repository.name}/pulls?q=author%3A#{@match.user.provider_login}+is%3Amerged+"} + target="_blank" + rel="noopener" + class="flex items-center gap-3 rounded-xl pr-2 bg-card/50 border border-border/50 hover:border-border transition-all" + > + {owner.name} +
    + + + {if owner.type == :organization do + owner.name + else + List.first(contributions).repository.name + end} + + <%= if tech = List.first(List.first(contributions).repository.tech_stack) do %> + + {tech} + + <% end %> + +
    + + <.icon name="tabler-star-filled" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact( + max(owner.stargazers_count, total_stars(contributions)) + )} + + + <.icon name="tabler-git-pull-request" class="h-4 w-4 mr-1" /> + {Algora.Util.format_number_compact(total_contributions(contributions))} + +
    +
    + + <% end %> +
    + <%!--
    <%= for {project, total_earned} <- @match.projects |> Enum.take(2) do %> <.link navigate={User.url(project)} @@ -1438,18 +1492,20 @@ defmodule AlgoraWeb.Org.DashboardLive do
    <% end %> -
    +
    --%>
    - Total payment for {@match.user.hours_per_week || 30} - hours -
    - (includes all platform and payment processing fees) -
    + Minimum payment to collaborate:
    + + {@match[:hourly_rate] + |> Money.mult!(@match.hours_per_week || 30) + |> Money.to_string!()} + + ({@match.hours_per_week || 30} hours booked)
    <.button @@ -1463,12 +1519,12 @@ defmodule AlgoraWeb.Org.DashboardLive do >
    Offer contract -
    - {@match[:hourly_rate] - |> Money.mult!(@match.user.hours_per_week || 30) - |> Bounties.calculate_contract_amount() - |> Money.to_string!(no_fraction_if_integer: false)} / week +
    + {@match[:hourly_rate]}/hr
    +
    + (includes all fees) +
    @@ -2070,4 +2126,24 @@ defmodule AlgoraWeb.Org.DashboardLive do defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M" defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}K" defp format_number(n), do: to_string(n) + + defp aggregate_contributions(contributions) do + groups = Enum.group_by(contributions, fn c -> c.repository.user end) + + contributions + |> Enum.map(fn c -> {c.repository.user, groups[c.repository.user]} end) + |> Enum.uniq_by(fn {owner, _} -> owner.id end) + end + + defp total_stars(contributions) do + contributions + |> Enum.map(& &1.repository.stargazers_count) + |> Enum.sum() + end + + defp total_contributions(contributions) do + contributions + |> Enum.map(& &1.contribution_count) + |> Enum.sum() + end end diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index e1bbbb9ae..0e16a40c3 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -132,11 +132,7 @@ defmodule AlgoraWeb.Org.Nav do bounty_res = Bounties.create_bounty( %{ - amount: - case data.contract_type do - :marketplace -> Bounties.calculate_contract_amount(amount) - :bring_your_own -> amount - end, + amount: amount, creator: socket.assigns.current_user, owner: socket.assigns.current_org, title: data.title,