From 56061cf5f2978aa72920109fc5ea5cbbf22bb121 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 18:27:03 +0300 Subject: [PATCH 01/50] adding new contract flow --- lib/algora/bounties/bounties.ex | 14 +- lib/algora/bounties/schemas/bounty.ex | 6 +- lib/algora/shared/types/usd.ex | 4 + lib/algora_web/forms/contract_form.ex | 205 +++++++++++++----- lib/algora_web/live/contract_live.ex | 59 +++-- lib/algora_web/live/org/dashboard_live.ex | 23 +- lib/algora_web/live/org/nav.ex | 8 +- ...0417150910_add_hourly_rate_to_bounties.exs | 9 + 8 files changed, 243 insertions(+), 85 deletions(-) create mode 100644 priv/repo/migrations/20250417150910_add_hourly_rate_to_bounties.exs diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 6d9409c26..1f8ad0482 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -52,7 +52,8 @@ 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 }) :: {:ok, Bounty.t()} | {:error, atom()} defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do @@ -64,7 +65,8 @@ 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] }) changeset @@ -112,6 +114,7 @@ defmodule Algora.Bounties do command_source: :ticket | :comment, visibility: Bounty.visibility() | nil, shared_with: [String.t()] | nil, + hourly_rate: Money.t() | nil, hours_per_week: integer() | nil ] ) :: @@ -144,6 +147,7 @@ defmodule Algora.Bounties do ticket: ticket, visibility: opts[:visibility], shared_with: shared_with, + hourly_rate: opts[:hourly_rate], hours_per_week: opts[:hours_per_week] }) @@ -195,7 +199,8 @@ 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 ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -215,7 +220,8 @@ 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] }), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do broadcast() diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index c85aadf29..b66d28566 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -4,11 +4,12 @@ defmodule Algora.Bounties.Bounty do alias Algora.Accounts.User alias Algora.Bounties.Bounty + alias Algora.Types.Money @type visibility :: :community | :exclusive | :public 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 @@ -16,6 +17,7 @@ defmodule Algora.Bounties.Bounty do 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 +36,7 @@ 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]) |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) |> generate_id() |> foreign_key_constraint(:ticket) diff --git a/lib/algora/shared/types/usd.ex b/lib/algora/shared/types/usd.ex index e8b7c8d6f..54a520221 100644 --- a/lib/algora/shared/types/usd.ex +++ b/lib/algora/shared/types/usd.ex @@ -17,6 +17,10 @@ defmodule Algora.Types.USD do end end + def cast(money) when is_struct(money, Money) do + {:ok, money} + end + def cast(_), do: :error @impl true diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index e0bc440bd..bd522b7df 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -13,6 +13,7 @@ defmodule AlgoraWeb.Forms.ContractForm do field :amount, USD field :hourly_rate, USD field :hours_per_week, :integer + field :marketplace?, :boolean, default: false field :type, Ecto.Enum, values: [:fixed, :hourly], default: :fixed field :title, :string field :description, :string @@ -56,68 +57,160 @@ defmodule AlgoraWeb.Forms.ContractForm do phx-change="validate_contract_main" >
+ <%= if contractor = get_field(@form.source, :contractor) do %> + <.card> + <.card_content> +
+ <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={contractor.avatar_url} alt={contractor.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(contractor.name)} + + + +
+
+ {contractor.name} + {Algora.Misc.CountryEmojis.get(contractor.country)} +
+ +
+ <.link + :if={contractor.provider_login} + href={"https://github.com/#{contractor.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="github" class="h-4 w-4" /> + {contractor.provider_login} + + <.link + :if={contractor.provider_meta["twitter_handle"]} + href={"https://x.com/#{contractor.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {contractor.provider_meta["twitter_handle"]} + + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {contractor.provider_meta["location"]} + +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {contractor.provider_meta["company"] |> String.trim_leading("@")} + +
+
+
+
+
+ <%= for tech <- contractor.tech_stack do %> +
+ {tech} +
+ <% end %> +
+ + + <% end %> + <.input label="Title" field={@form[:title]} /> <.input label="Description (optional)" field={@form[:description]} type="textarea" /> -
- -
- <%= for {label, value} <- type_options() do %> -
- -
- <.input label="Amount" icon="tabler-currency-dollar" field={@form[:amount]} /> -
-
- -
-
- {Money.to_string!(@bounty.amount)} 0} - class="text-base" - > - /hr - -
- <.button :if={@can_create_bounty} phx-click="reward"> - Pay - -
@@ -307,6 +293,51 @@ defmodule AlgoraWeb.ContractLive do + <.card :if={length(@transactions) == 0}> + <.card_header> + <.card_title> + Finalize offer + + + <.card_content class="pt-0"> +
+ + +
+
+ {Money.to_string!(Money.mult!(@bounty.amount, Decimal.new("1.13")))} +
+
+
+ Total amount for {@bounty.hours_per_week} + hours +
+ (includes all platform and payment processing fees) +
+
+
+ <.button :if={@can_create_bounty} phx-click="reward"> + Authorize + +
+
+ + <.card :if={length(@transactions) > 0}> <.card_header> <.card_title> diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index a16975ba8..40d30b5c6 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -632,24 +632,31 @@ defmodule AlgoraWeb.Org.DashboardLive do end @impl true - def handle_event("share_opportunity", %{"user_id" => user_id, "type" => "contract"}, socket) do + def handle_event( + "share_opportunity", + %{"user_id" => user_id, "type" => "contract", "marketplace" => marketplace?}, + socket + ) 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] - amount = - if hourly_rate = match[:hourly_rate] do - Money.mult!(hourly_rate, developer.hours_per_week || 30) - end + hours_per_week = developer.hours_per_week || 30 {:noreply, socket |> assign(:main_contract_form_open?, true) |> assign( :main_contract_form, - %ContractForm{} + %ContractForm{ + marketplace?: marketplace? == "true", + contractor: match.user || developer + } |> ContractForm.changeset(%{ + amount: if(hourly_rate, do: Money.mult!(hourly_rate, hours_per_week)), + hourly_rate: hourly_rate, contractor_handle: developer.provider_login, - amount: amount, + 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" }) @@ -1256,6 +1263,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="contract" + phx-value-marketplace="false" variant="none" class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" > @@ -1357,6 +1365,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-click="share_opportunity" phx-value-user_id={@match.user.id} phx-value-type="contract" + phx-value-marketplace="true" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index 74f008cf3..e7112c21d 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -126,18 +126,22 @@ defmodule AlgoraWeb.Org.Nav do amount = case data.type do :fixed -> data.amount - :hourly -> data.hourly_rate + :hourly -> Money.mult!(data.hourly_rate, data.hours_per_week) end + dbg(data.hourly_rate) + dbg(data.hours_per_week) + bounty_res = Bounties.create_bounty( %{ + amount: amount, creator: socket.assigns.current_user, owner: socket.assigns.current_org, - amount: amount, title: data.title, description: data.description }, + hourly_rate: data.hourly_rate, hours_per_week: data.hours_per_week, shared_with: [data.contractor.provider_id], visibility: :exclusive diff --git a/priv/repo/migrations/20250417150910_add_hourly_rate_to_bounties.exs b/priv/repo/migrations/20250417150910_add_hourly_rate_to_bounties.exs new file mode 100644 index 000000000..6306d3147 --- /dev/null +++ b/priv/repo/migrations/20250417150910_add_hourly_rate_to_bounties.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddHourlyRateToBounties do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :hourly_rate, :money_with_currency + end + end +end From 96d152108cb5b17f297b4549c8703b3b7b3bfa72 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 18:50:40 +0300 Subject: [PATCH 02/50] implement authorization flow --- lib/algora/bounties/bounties.ex | 41 ++++++++++++++++++++++++---- lib/algora/payments/payments.ex | 14 ++++++++-- lib/algora_web/live/contract_live.ex | 37 +++++++++++++++++++------ 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 1f8ad0482..cc451d74c 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -788,6 +788,27 @@ defmodule Algora.Bounties do ) 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()] + ) :: + {: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 + ) + end + @spec generate_line_items( %{owner: User.t(), amount: Money.t()}, opts :: [ @@ -861,7 +882,8 @@ 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 ] ) :: {:ok, String.t()} | {:error, atom()} @@ -876,6 +898,18 @@ 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 = + if capture_method = opts[:capture_method] do + Map.put(payment_intent_data, :capture_method, capture_method) + else + payment_intent_data + end + gross_amount = LineItem.gross_amount(line_items) bounty_id = if bounty = opts[:bounty], do: bounty.id @@ -905,10 +939,7 @@ 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) do {:ok, session.url} end end) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 5294ef1fe..6788760de 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -36,16 +36,24 @@ defmodule Algora.Payments do {:ok, PSP.session()} | {:error, PSP.error()} def create_stripe_session(user, line_items, payment_intent_data) do with {:ok, customer} <- fetch_or_create_customer(user) do - PSP.Session.create(%{ + opts = %{ mode: "payment", customer: customer.provider_id, billing_address_collection: "required", line_items: line_items, - invoice_creation: %{enabled: true}, success_url: "#{AlgoraWeb.Endpoint.url()}/payment/success", cancel_url: "#{AlgoraWeb.Endpoint.url()}/payment/canceled", payment_intent_data: payment_intent_data - }) + } + + opts = + if payment_intent_data[:capture_method] == :manual do + opts + else + Map.put(opts, :invoice_creation, %{enabled: true}) + end + + PSP.Session.create(opts) end end diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index e727da4da..b20b7cff4 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -18,7 +18,7 @@ defmodule AlgoraWeb.ContractLive do require Logger - defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] + # defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] defmodule RewardBountyForm do @moduledoc false @@ -109,7 +109,7 @@ defmodule AlgoraWeb.ContractLive do ticket_body_html = Algora.Markdown.render(bounty.ticket.description) reward_changeset = - RewardBountyForm.changeset(%RewardBountyForm{}, %{tip_percentage: 0}) + RewardBountyForm.changeset(%RewardBountyForm{}, %{amount: bounty.amount}) {:ok, thread} = Chat.get_or_create_bounty_thread(bounty) messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) @@ -233,6 +233,18 @@ defmodule AlgoraWeb.ContractLive do end end + @impl true + def handle_event("authorize_with_stripe", _params, socket) do + case authorize_payment(socket, socket.assigns.bounty) do + {:ok, session_url} -> + {:noreply, redirect(socket, external: session_url)} + + {:error, reason} -> + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + end + @impl true def handle_event(_event, _params, socket) do {:noreply, socket} @@ -508,13 +520,13 @@ defmodule AlgoraWeb.ContractLive do <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> <.drawer_header> - <.drawer_title>Pay Contract + <.drawer_title>Authorize payment <.drawer_description> - You can pay any amount at any time. + You will be charged once {@contractor.name} accepts the contract. <.drawer_content class="mt-4"> - <.form for={@reward_form} phx-change="validate_reward" phx-submit="pay_with_stripe"> + <.form for={@reward_form} phx-change="validate_reward" phx-submit="authorize_with_stripe">
<.card> @@ -527,9 +539,10 @@ defmodule AlgoraWeb.ContractLive do label="Amount" icon="tabler-currency-dollar" field={@reward_form[:amount]} + disabled /> -
+ <%!--
<.label>Tip
<.radio_group @@ -538,7 +551,7 @@ defmodule AlgoraWeb.ContractLive do options={tip_options()} />
-
+
--%>
@@ -590,7 +603,7 @@ defmodule AlgoraWeb.ContractLive do Cancel <.button type="submit"> - Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + Authorize with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
@@ -625,6 +638,14 @@ defmodule AlgoraWeb.ContractLive do ) end + defp authorize_payment(socket, bounty) do + Bounties.authorize_payment( + %{owner: bounty.owner, amount: bounty.amount, bounty: bounty, claims: []}, + ticket_ref: socket.assigns.ticket_ref, + recipient: socket.assigns.contractor + ) + end + defp calculate_final_amount(data_or_changeset) do tip_percentage = get_field(data_or_changeset, :tip_percentage) || Decimal.new(0) amount = get_field(data_or_changeset, :amount) || Money.zero(:USD, no_fraction_if_integer: true) From 2b6cb55e93208cc70b8b8ed81390f7a8539a0045 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 19:26:20 +0300 Subject: [PATCH 03/50] handle uncaptured charges --- lib/algora/accounts/accounts.ex | 4 +- lib/algora/payments/payments.ex | 9 +- lib/algora/payments/schemas/transaction.ex | 2 +- .../controllers/webhooks/stripe_controller.ex | 132 ++++++++++-------- lib/algora_web/live/contract_live.ex | 20 ++- lib/algora_web/live/org/nav.ex | 3 - lib/algora_web/live/org/transactions_live.ex | 14 +- 7 files changed, 106 insertions(+), 78 deletions(-) diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 67b938605..7a3a8a66d 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/payments/payments.ex b/lib/algora/payments/payments.ex index 6788760de..f0f9615ea 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -157,8 +157,13 @@ defmodule Algora.Payments do end def list_transactions(criteria \\ []) do - Transaction - |> where([t], ^Enum.to_list(criteria)) + criteria + |> Enum.reduce(Transaction, fn {key, value}, query -> + case value do + v when is_list(v) -> where(query, [t], field(t, ^key) in ^v) + v -> where(query, [t], field(t, ^key) == ^v) + end + end) |> preload(linked_transaction: :user) |> order_by([t], desc: t.inserted_at) |> Repo.all() diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex index 450b1f9ec..b06ca02f6 100644 --- a/lib/algora/payments/schemas/transaction.ex +++ b/lib/algora/payments/schemas/transaction.ex @@ -7,7 +7,7 @@ defmodule Algora.Payments.Transaction do alias Algora.Types.Money @transaction_types [:charge, :transfer, :reversal, :debit, :credit, :deposit, :withdrawal] - @transaction_statuses [:initialized, :processing, :succeeded, :failed, :canceled] + @transaction_statuses [:initialized, :processing, :requires_capture, :succeeded, :failed, :canceled] @derive {Inspect, except: [:provider_meta]} typed_schema "transactions" do diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 3f2ef66e2..bcb351c78 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -129,77 +129,87 @@ defmodule AlgoraWeb.Webhooks.StripeController do end defp process_charge_succeeded( - %Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{id: charge_id}}}, + %Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{id: charge_id, captured: captured}}}, group_id ) when is_binary(group_id) do Repo.transact(fn -> + status = if captured, do: :succeeded, else: :requires_capture + succeeded_at = if captured, do: DateTime.utc_now() + {_, txs} = Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), - set: [status: :succeeded, succeeded_at: DateTime.utc_now()] + set: [ + status: status, + succeeded_at: succeeded_at, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id + ] ) - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id), - set: [provider: "stripe", provider_id: charge_id] - ) - - bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - - Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) - Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) - Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) - # TODO: add and use a new "paid" status for claims - Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) - - activities_result = - txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn tx, :ok -> - case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do - {:ok, _} -> {:cont, :ok} - error -> {:halt, error} - end - end) - - jobs_result = - txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn credit, :ok -> - case Payments.fetch_active_account(credit.user_id) do - {:ok, _account} -> - case %{credit_id: credit.id} - |> Payments.Jobs.ExecutePendingTransfer.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - - {:error, :no_active_account} -> - case %{credit_id: credit.id} - |> Bounties.Jobs.PromptPayoutConnect.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - end - end) - - with txs when txs != [] <- txs, - :ok <- activities_result, - :ok <- jobs_result do + if status == :succeeded do + bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + + Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) + Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) + Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) + # TODO: add and use a new "paid" status for claims + Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) + + activities_result = + txs + |> Enum.filter(&(&1.type == :credit)) + |> Enum.reduce_while(:ok, fn tx, :ok -> + case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do + {:ok, _} -> {:cont, :ok} + error -> {:halt, error} + end + end) + + jobs_result = + txs + |> Enum.filter(&(&1.type == :credit)) + |> Enum.reduce_while(:ok, fn credit, :ok -> + case Payments.fetch_active_account(credit.user_id) do + {:ok, _account} -> + case %{credit_id: credit.id} + |> Payments.Jobs.ExecutePendingTransfer.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, :no_active_account} -> + case %{credit_id: credit.id} + |> Bounties.Jobs.PromptPayoutConnect.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + end + end) + + with txs when txs != [] <- txs, + :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") + {:error, :failed_to_update_transactions} + end + else 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") - {:error, :failed_to_update_transactions} end end) end diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index b20b7cff4..75d77dd97 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -674,11 +674,13 @@ defmodule AlgoraWeb.ContractLive do defp assign_transactions(socket) do transactions = - Payments.list_transactions( + [ user_id: socket.assigns.bounty.owner.id, - status: :succeeded, + status: [:succeeded, :requires_capture], bounty_id: socket.assigns.bounty.id - ) + ] + |> Payments.list_transactions() + |> Enum.filter(&(&1.type == :charge or &1.status == :succeeded)) balance = calculate_balance(transactions) volume = calculate_volume(transactions) @@ -690,7 +692,9 @@ defmodule AlgoraWeb.ContractLive do end defp calculate_balance(transactions) do - Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + transactions + |> Enum.filter(&(&1.status == :succeeded)) + |> Enum.reduce(Money.new!(0, :USD), fn transaction, acc -> case transaction.type do type when type in [:charge, :deposit, :credit] -> Money.add!(acc, transaction.net_amount) @@ -705,7 +709,9 @@ defmodule AlgoraWeb.ContractLive do end defp calculate_volume(transactions) do - Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + transactions + |> Enum.filter(&(&1.status == :succeeded)) + |> Enum.reduce(Money.new!(0, :USD), fn transaction, acc -> case transaction.type do type when type in [:charge, :credit] -> Money.add!(acc, transaction.net_amount) _ -> acc @@ -720,7 +726,9 @@ defmodule AlgoraWeb.ContractLive do end end - defp description(%{type: :charge}), do: "Escrowed" + defp description(%{type: :charge, status: :requires_capture}), do: "Authorized" + + defp description(%{type: :charge, status: :succeeded}), do: "Escrowed" defp description(%{type: :debit}), do: "Released" diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index e7112c21d..de25bc8f0 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -129,9 +129,6 @@ defmodule AlgoraWeb.Org.Nav do :hourly -> Money.mult!(data.hourly_rate, data.hours_per_week) end - dbg(data.hourly_rate) - dbg(data.hours_per_week) - bounty_res = Bounties.create_bounty( %{ diff --git a/lib/algora_web/live/org/transactions_live.ex b/lib/algora_web/live/org/transactions_live.ex index ccc1196b8..b901d7252 100644 --- a/lib/algora_web/live/org/transactions_live.ex +++ b/lib/algora_web/live/org/transactions_live.ex @@ -137,11 +137,13 @@ defmodule AlgoraWeb.Org.TransactionsLive do defp assign_transactions(socket) do transactions = - Payments.list_transactions( + [ user_id: socket.assigns.current_org.id, # TODO: also list transactions that are "processing" - status: :succeeded - ) + status: [:succeeded, :requires_capture] + ] + |> Payments.list_transactions() + |> Enum.filter(&(&1.type == :charge or &1.status == :succeeded)) balance = calculate_balance(transactions) volume = calculate_volume(transactions) @@ -153,7 +155,9 @@ defmodule AlgoraWeb.Org.TransactionsLive do end defp calculate_balance(transactions) do - Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + transactions + |> Enum.filter(&(&1.status == :succeeded)) + |> Enum.reduce(Money.new!(0, :USD), fn transaction, acc -> case transaction.type do type when type in [:charge, :deposit, :credit] -> Money.add!(acc, transaction.net_amount) @@ -484,6 +488,8 @@ defmodule AlgoraWeb.Org.TransactionsLive do end end + defp description(%{type: :charge, status: :requires_capture}), do: "Authorization" + defp description(%{type: type, tip_id: tip_id}) when type in [:debit, :credit] and not is_nil(tip_id), do: "Tip payment" defp description(%{type: type, contract_id: contract_id}) when type in [:debit, :credit] and not is_nil(contract_id), From 998a7302857ddb7c450e61b905cf2308f7c028c5 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 19:46:33 +0300 Subject: [PATCH 04/50] handle release flow --- lib/algora/psp/psp.ex | 1 + .../controllers/webhooks/stripe_controller.ex | 19 ++++++----- lib/algora_web/live/contract_live.ex | 34 ++++++++++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/lib/algora/psp/psp.ex b/lib/algora/psp/psp.ex index 8559556db..75867ca8c 100644 --- a/lib/algora/psp/psp.ex +++ b/lib/algora/psp/psp.ex @@ -130,6 +130,7 @@ defmodule Algora.PSP do @type t :: Stripe.PaymentIntent.t() def create(params), do: Algora.PSP.client(__MODULE__).create(params) + def capture(id, params \\ %{}), do: Algora.PSP.client(__MODULE__).capture(id, params) end @type setup_intent :: Algora.PSP.SetupIntent.t() diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index bcb351c78..1ef80bdc6 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -49,17 +49,16 @@ defmodule AlgoraWeb.Webhooks.StripeController do defp process_event( %Stripe.Event{ - type: "charge.succeeded", + type: type, data: %{object: %Stripe.Charge{metadata: %{"version" => @metadata_version, "group_id" => group_id}}} } = event ) - when is_binary(group_id) do + when type in ["charge.succeeded", "charge.captured"] and is_binary(group_id) do process_charge_succeeded(event, group_id) end - defp process_event( - %Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{invoice: invoice_id}}} = event - ) do + defp process_event(%Stripe.Event{type: type, data: %{object: %Stripe.Charge{invoice: invoice_id}}} = event) + when type in ["charge.succeeded", "charge.captured"] do with {:ok, invoice} <- Algora.PSP.Invoice.retrieve(invoice_id), %{"version" => @metadata_version, "group_id" => group_id} <- invoice.metadata do process_charge_succeeded(event, group_id) @@ -129,10 +128,13 @@ defmodule AlgoraWeb.Webhooks.StripeController do end defp process_charge_succeeded( - %Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{id: charge_id, captured: captured}}}, + %Stripe.Event{ + type: type, + data: %{object: %Stripe.Charge{id: charge_id, captured: captured, payment_intent: payment_intent_id}} + }, group_id ) - when is_binary(group_id) do + when type in ["charge.succeeded", "charge.captured"] and is_binary(group_id) do Repo.transact(fn -> status = if captured, do: :succeeded, else: :requires_capture succeeded_at = if captured, do: DateTime.utc_now() @@ -144,7 +146,8 @@ defmodule AlgoraWeb.Webhooks.StripeController do succeeded_at: succeeded_at, provider: "stripe", provider_id: charge_id, - provider_charge_id: charge_id + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id ] ) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 75d77dd97..6f35a459e 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -117,6 +117,7 @@ defmodule AlgoraWeb.ContractLive do if connected?(socket) do Chat.subscribe(thread.id) + Payments.subscribe() end share_url = @@ -173,6 +174,10 @@ defmodule AlgoraWeb.ContractLive do {:noreply, socket} end + def handle_info(:payments_updated, socket) do + {:noreply, assign_transactions(socket)} + end + @impl true def handle_event("send_message", %{"message" => content}, socket) do {:ok, message} = @@ -245,6 +250,19 @@ defmodule AlgoraWeb.ContractLive do end end + @impl true + def handle_event("release_funds", %{"payment_intent_id" => payment_intent_id}, socket) do + case Algora.PSP.PaymentIntent.capture(payment_intent_id) do + {:ok, payment_intent} -> + dbg(payment_intent) + {:noreply, put_flash(socket, :info, "Funds released!")} + + {:error, reason} -> + Logger.error("Failed to capture payment intent: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + end + @impl true def handle_event(_event, _params, socket) do {:noreply, socket} @@ -385,7 +403,21 @@ defmodule AlgoraWeb.ContractLive do - {description(transaction)} +
+ {description(transaction)} + <.button + :if={ + transaction.type == :charge and + transaction.status == :requires_capture + } + size="sm" + phx-click="release_funds" + phx-disable-with="Releasing..." + phx-value-payment_intent_id={transaction.provider_payment_intent_id} + > + Release funds + +
<%= case transaction_direction(transaction.type) do %> From a402b82441e721134f4171c49fd9e40cf2e4c875 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 20:31:49 +0300 Subject: [PATCH 05/50] implement 3 step flow --- lib/algora/bounties/bounties.ex | 22 ++- lib/algora/payments/payments.ex | 150 +++++++++++++++++- lib/algora/payments/schemas/transaction.ex | 2 +- lib/algora/psp/psp.ex | 8 + .../controllers/webhooks/stripe_controller.ex | 111 +------------ lib/algora_web/live/contract_live.ex | 78 ++++++--- 6 files changed, 231 insertions(+), 140 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index cc451d74c..e75af0995 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -805,7 +805,9 @@ defmodule Algora.Bounties do bounty: bounty, claims: claims, recipient: opts[:recipient], - capture_method: :manual + capture_method: :manual, + success_url: opts[:success_url], + cancel_url: opts[:cancel_url] ) end @@ -883,7 +885,9 @@ defmodule Algora.Bounties do bounty: Bounty.t(), claims: [Claim.t()], recipient: User.t(), - capture_method: :automatic | :automatic_async | :manual + capture_method: :automatic | :automatic_async | :manual, + success_url: String.t(), + cancel_url: String.t() ] ) :: {:ok, String.t()} | {:error, atom()} @@ -903,11 +907,12 @@ defmodule Algora.Bounties do metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id} } - payment_intent_data = + {payment_intent_data, session_opts} = if capture_method = opts[:capture_method] do - Map.put(payment_intent_data, :capture_method, capture_method) + {Map.put(payment_intent_data, :capture_method, capture_method), + [success_url: opts[:success_url], cancel_url: opts[:cancel_url]]} else - payment_intent_data + {payment_intent_data, []} end gross_amount = LineItem.gross_amount(line_items) @@ -939,7 +944,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), payment_intent_data) 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) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index f0f9615ea..bd128df81 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -5,6 +5,11 @@ defmodule Algora.Payments do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Bounties + alias Algora.Bounties.Bounty + alias Algora.Bounties.Claim + alias Algora.Bounties.Tip + alias Algora.Contracts.Contract alias Algora.MoneyUtils alias Algora.Payments.Account alias Algora.Payments.Customer @@ -31,18 +36,19 @@ defmodule Algora.Payments do @spec create_stripe_session( user :: User.t(), line_items :: [PSP.Session.line_item_data()], - payment_intent_data :: PSP.Session.payment_intent_data() + payment_intent_data :: PSP.Session.payment_intent_data(), + opts :: Keyword.t() ) :: {:ok, PSP.session()} | {:error, PSP.error()} - def create_stripe_session(user, line_items, payment_intent_data) do + def create_stripe_session(user, line_items, payment_intent_data, opts \\ []) do with {:ok, customer} <- fetch_or_create_customer(user) do opts = %{ mode: "payment", customer: customer.provider_id, billing_address_collection: "required", line_items: line_items, - success_url: "#{AlgoraWeb.Endpoint.url()}/payment/success", - cancel_url: "#{AlgoraWeb.Endpoint.url()}/payment/canceled", + success_url: opts[:success_url] || "#{AlgoraWeb.Endpoint.url()}/payment/success", + cancel_url: opts[:cancel_url] || "#{AlgoraWeb.Endpoint.url()}/payment/canceled", payment_intent_data: payment_intent_data } @@ -544,4 +550,140 @@ defmodule Algora.Payments do {:error, error} end end + + def process_charge(%Stripe.Event{type: "charge.succeeded", data: %{object: %Stripe.Charge{}}}, group_id) + when not is_binary(group_id) do + {:error, :invalid_group_id} + end + + def process_charge( + "charge.succeeded", + %Stripe.Charge{id: charge_id, captured: false, payment_intent: payment_intent_id}, + group_id + ) do + Repo.transact(fn -> + Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type == :charge), + set: [ + status: :requires_capture, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id + ] + ) + + broadcast() + {:ok, nil} + end) + end + + def process_charge( + "charge.captured", + %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, + group_id + ) do + Repo.transact(fn -> + Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type == :charge), + set: [ + status: :succeeded, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id + ] + ) + + Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, where: t.type != :charge), + set: [ + status: :requires_release, + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id + ] + ) + + broadcast() + {:ok, nil} + end) + end + + def process_charge( + "charge.succeeded", + %Stripe.Charge{id: charge_id, captured: true, payment_intent: payment_intent_id}, + group_id + ) do + Repo.transact(fn -> + {_, txs} = + Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), + set: [ + status: :succeeded, + succeeded_at: DateTime.utc_now(), + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id + ] + ) + + bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + + Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) + Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) + Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) + # TODO: add and use a new "paid" status for claims + Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) + + activities_result = + txs + |> Enum.filter(&(&1.type == :credit)) + |> Enum.reduce_while(:ok, fn tx, :ok -> + case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do + {:ok, _} -> {:cont, :ok} + error -> {:halt, error} + end + end) + + jobs_result = + txs + |> Enum.filter(&(&1.type == :credit)) + |> Enum.reduce_while(:ok, fn credit, :ok -> + case fetch_active_account(credit.user_id) do + {:ok, _account} -> + case %{credit_id: credit.id} + |> Jobs.ExecutePendingTransfer.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + + {:error, :no_active_account} -> + case %{credit_id: credit.id} + |> Bounties.Jobs.PromptPayoutConnect.new() + |> Oban.insert() do + {:ok, _job} -> {:cont, :ok} + error -> {:halt, error} + end + end + end) + + with txs when txs != [] <- txs, + :ok <- activities_result, + :ok <- jobs_result do + 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 end diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex index b06ca02f6..33e5d3fa5 100644 --- a/lib/algora/payments/schemas/transaction.ex +++ b/lib/algora/payments/schemas/transaction.ex @@ -7,7 +7,7 @@ defmodule Algora.Payments.Transaction do alias Algora.Types.Money @transaction_types [:charge, :transfer, :reversal, :debit, :credit, :deposit, :withdrawal] - @transaction_statuses [:initialized, :processing, :requires_capture, :succeeded, :failed, :canceled] + @transaction_statuses [:initialized, :processing, :requires_capture, :requires_release, :succeeded, :failed, :canceled] @derive {Inspect, except: [:provider_meta]} typed_schema "transactions" do diff --git a/lib/algora/psp/psp.ex b/lib/algora/psp/psp.ex index 75867ca8c..2d644e925 100644 --- a/lib/algora/psp/psp.ex +++ b/lib/algora/psp/psp.ex @@ -133,6 +133,14 @@ defmodule Algora.PSP do def capture(id, params \\ %{}), do: Algora.PSP.client(__MODULE__).capture(id, params) end + @type charge :: Algora.PSP.Charge.t() + defmodule Charge do + @moduledoc false + + @type t :: Stripe.Charge.t() + def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id) + end + @type setup_intent :: Algora.PSP.SetupIntent.t() defmodule SetupIntent do @moduledoc false diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 1ef80bdc6..644c1c888 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -2,13 +2,8 @@ defmodule AlgoraWeb.Webhooks.StripeController do @behaviour Stripe.WebhookHandler import Ecto.Changeset - import Ecto.Query alias Algora.Bounties - alias Algora.Bounties.Bounty - alias Algora.Bounties.Claim - alias Algora.Bounties.Tip - alias Algora.Contracts.Contract alias Algora.Payments alias Algora.Payments.Customer alias Algora.Payments.Transaction @@ -47,21 +42,19 @@ defmodule AlgoraWeb.Webhooks.StripeController do {:error, error} end - defp process_event( - %Stripe.Event{ - type: type, - data: %{object: %Stripe.Charge{metadata: %{"version" => @metadata_version, "group_id" => group_id}}} - } = event - ) + defp process_event(%Stripe.Event{ + type: type, + data: %{object: %Stripe.Charge{metadata: %{"version" => @metadata_version, "group_id" => group_id}} = charge} + }) when type in ["charge.succeeded", "charge.captured"] and is_binary(group_id) do - process_charge_succeeded(event, group_id) + Payments.process_charge(type, charge, group_id) end - defp process_event(%Stripe.Event{type: type, data: %{object: %Stripe.Charge{invoice: invoice_id}}} = event) + defp process_event(%Stripe.Event{type: type, data: %{object: %Stripe.Charge{invoice: invoice_id} = charge}}) when type in ["charge.succeeded", "charge.captured"] do with {:ok, invoice} <- Algora.PSP.Invoice.retrieve(invoice_id), %{"version" => @metadata_version, "group_id" => group_id} <- invoice.metadata do - process_charge_succeeded(event, group_id) + Payments.process_charge(type, charge, group_id) end end @@ -127,96 +120,6 @@ defmodule AlgoraWeb.Webhooks.StripeController do end end - defp process_charge_succeeded( - %Stripe.Event{ - type: type, - data: %{object: %Stripe.Charge{id: charge_id, captured: captured, payment_intent: payment_intent_id}} - }, - group_id - ) - when type in ["charge.succeeded", "charge.captured"] and is_binary(group_id) do - Repo.transact(fn -> - status = if captured, do: :succeeded, else: :requires_capture - succeeded_at = if captured, do: DateTime.utc_now() - - {_, txs} = - Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), - set: [ - status: status, - succeeded_at: succeeded_at, - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] - ) - - if status == :succeeded do - bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - - Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) - Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) - Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) - # TODO: add and use a new "paid" status for claims - Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) - - activities_result = - txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn tx, :ok -> - case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do - {:ok, _} -> {:cont, :ok} - error -> {:halt, error} - end - end) - - jobs_result = - txs - |> Enum.filter(&(&1.type == :credit)) - |> Enum.reduce_while(:ok, fn credit, :ok -> - case Payments.fetch_active_account(credit.user_id) do - {:ok, _account} -> - case %{credit_id: credit.id} - |> Payments.Jobs.ExecutePendingTransfer.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - - {:error, :no_active_account} -> - case %{credit_id: credit.id} - |> Bounties.Jobs.PromptPayoutConnect.new() - |> Oban.insert() do - {:ok, _job} -> {:cont, :ok} - error -> {:halt, error} - end - end - end) - - with txs when txs != [] <- txs, - :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") - {:error, :failed_to_update_transactions} - end - else - Payments.broadcast() - {:ok, nil} - end - end) - end - defp alert(%Stripe.Event{} = event, :ok) do Algora.Admin.alert("Stripe event: #{event.type} #{event.id} https://dashboard.stripe.com/logs?success=true", :debug) end diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 6f35a459e..a3c943e5f 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -251,15 +251,35 @@ defmodule AlgoraWeb.ContractLive do end @impl true - def handle_event("release_funds", %{"payment_intent_id" => payment_intent_id}, socket) do - case Algora.PSP.PaymentIntent.capture(payment_intent_id) do - {:ok, payment_intent} -> - dbg(payment_intent) - {:noreply, put_flash(socket, :info, "Funds released!")} + 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")} + _ -> + Logger.error("Failed to release funds: transaction not found") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + end + + @impl true + def handle_event("accept_contract", %{"tx_id" => tx_id}, socket) do + with tx when not is_nil(tx) <- Enum.find(socket.assigns.transactions, &(&1.id == tx_id)), + {:ok, _payment_intent} <- Algora.PSP.PaymentIntent.capture(tx.provider_payment_intent_id) do + {:noreply, put_flash(socket, :info, "Contract accepted!")} + else {:error, reason} -> Logger.error("Failed to capture payment intent: #{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 end @@ -308,6 +328,15 @@ defmodule AlgoraWeb.ContractLive do + <%= if transaction = Enum.find(@transactions, fn tx -> tx.type == :charge and tx.status == :requires_capture end) do %> + <.button + phx-click="accept_contract" + phx-disable-with="Accepting..." + phx-value-tx_id={transaction.id} + > + Accept contract + + <% end %> @@ -407,29 +436,22 @@ defmodule AlgoraWeb.ContractLive do {description(transaction)} <.button :if={ - transaction.type == :charge and - transaction.status == :requires_capture + transaction.type == :debit and + transaction.status == :requires_release } size="sm" phx-click="release_funds" phx-disable-with="Releasing..." - phx-value-payment_intent_id={transaction.provider_payment_intent_id} + phx-value-tx_id={transaction.id} > Release funds - <%= case transaction_direction(transaction.type) do %> - <% :plus -> %> - - {Money.to_string!(transaction.net_amount)} - - <% :minus -> %> - - {Money.to_string!(transaction.net_amount)} - - <% end %> + + {Money.to_string!(transaction.net_amount)} + <% end %> @@ -674,7 +696,9 @@ defmodule AlgoraWeb.ContractLive do Bounties.authorize_payment( %{owner: bounty.owner, amount: bounty.amount, bounty: bounty, claims: []}, ticket_ref: socket.assigns.ticket_ref, - recipient: socket.assigns.contractor + recipient: socket.assigns.contractor, + success_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}"), + cancel_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}") ) end @@ -708,11 +732,11 @@ defmodule AlgoraWeb.ContractLive do transactions = [ user_id: socket.assigns.bounty.owner.id, - status: [:succeeded, :requires_capture], + status: [:succeeded, :requires_capture, :requires_release], bounty_id: socket.assigns.bounty.id ] |> Payments.list_transactions() - |> Enum.filter(&(&1.type == :charge or &1.status == :succeeded)) + |> Enum.filter(&(&1.type == :charge or &1.status in [:succeeded, :requires_release])) balance = calculate_balance(transactions) volume = calculate_volume(transactions) @@ -751,10 +775,12 @@ defmodule AlgoraWeb.ContractLive do end) end - defp transaction_direction(type) do + defp transaction_color(%{type: :debit, status: :requires_release}), do: "text-emerald-400/50" + + defp transaction_color(%{type: type}) do case type do - t when t in [:charge, :credit, :deposit] -> :minus - t when t in [:debit, :withdrawal, :transfer] -> :plus + t when t in [:charge, :credit, :deposit] -> "text-foreground" + t when t in [:debit, :withdrawal, :transfer] -> "text-emerald-400" end end @@ -762,7 +788,9 @@ defmodule AlgoraWeb.ContractLive do defp description(%{type: :charge, status: :succeeded}), do: "Escrowed" - defp description(%{type: :debit}), do: "Released" + defp description(%{type: :debit, status: :requires_release}), do: "Ready to release" + + defp description(%{type: :debit, status: :succeeded}), do: "Released" defp description(%{type: _type}), do: nil end From b2b5acb8b26ade249841372969eee64759dead65 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 21:02:17 +0300 Subject: [PATCH 06/50] feat: enhance contributor queries to exclude organization members --- lib/algora/workspace/workspace.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index 2d3a9fdee..c5306977f 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -6,6 +6,7 @@ defmodule Algora.Workspace do alias Algora.Accounts alias Algora.Accounts.User alias Algora.Github + alias Algora.Organizations.Member alias Algora.Repo alias Algora.Util alias Algora.Workspace.CommandResponse @@ -531,6 +532,9 @@ defmodule Algora.Workspace do join: u in assoc(c, :user), where: u.type != :bot, where: not ilike(u.provider_login, "%bot"), + left_join: m in Member, + on: m.user_id == u.id and m.org_id == r.user_id, + where: is_nil(m.id), select_merge: %{user: u}, order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] ) @@ -547,6 +551,9 @@ defmodule Algora.Workspace do join: u in assoc(c, :user), where: u.type != :bot, where: not ilike(u.provider_login, "%bot"), + left_join: m in Member, + on: m.user_id == u.id and m.org_id == r.user_id, + where: is_nil(m.id), distinct: [c.user_id], select_merge: %{user: u}, order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id], From 1cd1d40815fde9c6f59023c81925d582932fc1b2 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 17 Apr 2025 21:02:28 +0300 Subject: [PATCH 07/50] refactor: update contract form labels and enhance dashboard live conditions --- lib/algora_web/forms/contract_form.ex | 10 ++++++---- lib/algora_web/live/org/dashboard_live.ex | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index bd522b7df..0d559766c 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -198,9 +198,14 @@ defmodule AlgoraWeb.Forms.ContractForm do
- Total amount for + Total payment for {get_change(@form.source, :hours_per_week)} hours + <%= if contractor = get_field(@form.source, :contractor) do %> + + ({contractor.name}'s availability) + + <% end %>
(includes all platform and payment processing fees)
@@ -220,9 +225,6 @@ defmodule AlgoraWeb.Forms.ContractForm do Draft contract <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
-

- You can edit the contract after it's created -

""" end diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 40d30b5c6..f9df270bd 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1964,7 +1964,7 @@ defmodule AlgoraWeb.Org.DashboardLive do <.drawer_content :if={@selected_developer} class="mt-4">
<.share_drawer_developer_info selected_developer={@selected_developer} /> - <%= if incomplete?(@achievements, :connect_github_status) do %> + <%= if @live_action == :preview or incomplete?(@achievements, :connect_github_status) do %>
From 0eee9e9eaab0062c9cb38fe7c6f1d4bda1ab3a25 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:12:15 +0300 Subject: [PATCH 08/50] add missing event --- lib/algora_web/controllers/webhooks/stripe_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/controllers/webhooks/stripe_controller.ex b/lib/algora_web/controllers/webhooks/stripe_controller.ex index 644c1c888..23aba882f 100644 --- a/lib/algora_web/controllers/webhooks/stripe_controller.ex +++ b/lib/algora_web/controllers/webhooks/stripe_controller.ex @@ -99,7 +99,7 @@ defmodule AlgoraWeb.Webhooks.StripeController do end defp process_event(%Stripe.Event{type: type} = event) - when type in ["charge.succeeded", "transfer.created", "checkout.session.completed"] do + when type in ["charge.succeeded", "charge.captured", "transfer.created", "checkout.session.completed"] do Algora.Admin.alert("Unhandled Stripe event: #{event.type} #{event.id}", :error) :ok end From 46d7110f62570c3bbaa2f27866272197a0f10dd2 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:12:37 +0300 Subject: [PATCH 09/50] add missing typespecs for params --- lib/algora/bounties/bounties.ex | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index e75af0995..300eb9561 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -795,7 +795,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 authorize_payment(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do From 7fcbc6316efc30881f2882a41803381e5563adf9 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:12:46 +0300 Subject: [PATCH 10/50] add missing mock fields --- .../algora_web/controllers/webhooks/stripe_controller_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index b7760b721..235254033 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -68,6 +68,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do type: "charge.succeeded", data: %{ object: %Stripe.Charge{ + captured: true, metadata: Map.put(metadata, "group_id", group_id) } } @@ -129,6 +130,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do type: "charge.succeeded", data: %{ object: %Stripe.Charge{ + captured: true, metadata: Map.put(metadata, "group_id", group_id) } } @@ -170,6 +172,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do type: "charge.succeeded", data: %{ object: %Stripe.Charge{ + captured: true, metadata: Map.put(metadata, "group_id", group_id) } } From 184681498ded0d8aa7ec465a056be4039a3e2e31 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:20:43 +0300 Subject: [PATCH 11/50] display user card only on marketplace mode --- lib/algora_web/forms/contract_form.ex | 122 +++++++++++++------------- 1 file changed, 62 insertions(+), 60 deletions(-) diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index 0d559766c..a7ffff874 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -57,71 +57,73 @@ defmodule AlgoraWeb.Forms.ContractForm do phx-change="validate_contract_main" >
- <%= if contractor = get_field(@form.source, :contractor) do %> - <.card> - <.card_content> -
- <.avatar class="h-16 w-16 rounded-full"> - <.avatar_image src={contractor.avatar_url} alt={contractor.name} /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(contractor.name)} - - - -
-
- {contractor.name} - {Algora.Misc.CountryEmojis.get(contractor.country)} -
+ <%= if get_field(@form.source, :marketplace?) do %> + <%= if contractor = get_field(@form.source, :contractor) do %> + <.card> + <.card_content> +
+ <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={contractor.avatar_url} alt={contractor.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(contractor.name)} + + + +
+
+ {contractor.name} + {Algora.Misc.CountryEmojis.get(contractor.country)} +
-
- <.link - :if={contractor.provider_login} - href={"https://github.com/#{contractor.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" +
- <.icon name="github" class="h-4 w-4" /> - {contractor.provider_login} - - <.link - :if={contractor.provider_meta["twitter_handle"]} - href={"https://x.com/#{contractor.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="tabler-brand-x" class="h-4 w-4" /> - - {contractor.provider_meta["twitter_handle"]} - - -
- <.icon name="tabler-map-pin" class="h-4 w-4" /> - - {contractor.provider_meta["location"]} - -
-
- <.icon name="tabler-building" class="h-4 w-4" /> - - {contractor.provider_meta["company"] |> String.trim_leading("@")} - + <.link + :if={contractor.provider_login} + href={"https://github.com/#{contractor.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="github" class="h-4 w-4" /> + {contractor.provider_login} + + <.link + :if={contractor.provider_meta["twitter_handle"]} + href={"https://x.com/#{contractor.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {contractor.provider_meta["twitter_handle"]} + + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {contractor.provider_meta["location"]} + +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {contractor.provider_meta["company"] |> String.trim_leading("@")} + +
-
-
- <%= for tech <- contractor.tech_stack do %> -
- {tech} -
- <% end %> -
- - +
+ <%= for tech <- contractor.tech_stack do %> +
+ {tech} +
+ <% end %> +
+ + + <% end %> <% end %> <.input label="Title" field={@form[:title]} /> From 2a77d99fcb246b2e0ba6f85b85f116a6cf20fa4e Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:20:52 +0300 Subject: [PATCH 12/50] fix: handle nil case --- lib/algora_web/live/org/dashboard_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index f9df270bd..3fd4f7193 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -650,7 +650,7 @@ defmodule AlgoraWeb.Org.DashboardLive do :main_contract_form, %ContractForm{ marketplace?: marketplace? == "true", - contractor: match.user || developer + contractor: match[:user] || developer } |> ContractForm.changeset(%{ amount: if(hourly_rate, do: Money.mult!(hourly_rate, hours_per_week)), From 87657719d85c9c51bd86ab617719ddf80246d9c1 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:25:59 +0300 Subject: [PATCH 13/50] update finalize contract steps --- lib/algora_web/live/contract_live.ex | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index a3c943e5f..ebbcf6b36 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -361,18 +361,20 @@ defmodule AlgoraWeb.ContractLive do <.card_content class="pt-0">
    -
  • - <.icon name="tabler-circle-number-1" class="size-8 text-success-400" /> +
  • + <.icon name="tabler-circle-number-1 mr-2" class="size-8 text-success-400" /> Authorize the payment to share the contract offer with {@contractor.name}
  • -
  • - <.icon name="tabler-circle-number-2" class="size-8 text-success-400" /> - When {@contractor.name} accepts, you will be charged {Money.to_string!( - @bounty.amount - )} into escrow +
  • + <.icon name="tabler-circle-number-2 mr-2" class="size-8 text-success-400" /> + When {@contractor.name} accepts, you will be charged + + {Money.to_string!(Money.mult!(@bounty.amount, Decimal.new("1.13")))} + + into escrow
  • -
  • - <.icon name="tabler-circle-number-3" class="size-8 text-success-400" /> +
  • + <.icon name="tabler-circle-number-3 mr-2" class="size-8 text-success-400" /> At the end of the week, release or withhold the funds based on {@contractor.name}'s performance
From 25e0bd652a97495f6ca32f1c999da2fac0088a93 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 15:39:01 +0300 Subject: [PATCH 14/50] hide buttons based on current_user --- lib/algora_web/live/contract_live.ex | 35 ++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index ebbcf6b36..ebdb5dd87 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -329,13 +329,25 @@ defmodule AlgoraWeb.ContractLive do
<%= if transaction = Enum.find(@transactions, fn tx -> tx.type == :charge and tx.status == :requires_capture end) do %> - <.button - phx-click="accept_contract" - phx-disable-with="Accepting..." - phx-value-tx_id={transaction.id} - > - Accept contract - + <%= if @current_user && @current_user.id == @contractor.id do %> + <.button + phx-click="accept_contract" + phx-disable-with="Accepting..." + phx-value-tx_id={transaction.id} + > + Accept contract + + <% else %> + <.badge variant="warning" class="mb-auto"> + Offer sent + + <% end %> + <% end %> + + <%= if _transaction = Enum.find(@transactions, fn tx -> tx.type == :debit end) do %> + <.badge variant="success" class="mb-auto"> + Active + <% end %>
@@ -352,7 +364,7 @@ defmodule AlgoraWeb.ContractLive do
- <.card :if={length(@transactions) == 0}> + <.card :if={length(@transactions) == 0 and @can_create_bounty}> <.card_header> <.card_title> Finalize offer @@ -392,7 +404,7 @@ defmodule AlgoraWeb.ContractLive do
- <.button :if={@can_create_bounty} phx-click="reward"> + <.button phx-click="reward"> Authorize
@@ -422,7 +434,10 @@ defmodule AlgoraWeb.ContractLive do <%= for transaction <- @transactions do %> - +
{Util.timestamp(transaction.inserted_at, @timezone)} From 2fc0131118ada70b5e85b93d1e60175cb4967ea8 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 18 Apr 2025 21:54:09 +0300 Subject: [PATCH 15/50] style: dashboard buttons --- lib/algora_web/live/org/dashboard_live.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 3fd4f7193..dcae2b498 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1228,7 +1228,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="bounty" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-300 hover:drop-shadow-[0_1px_5px_#60a5fa80] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#60a5fa80] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + class="group bg-blue-900/10 text-blue-300 transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-300 hover:drop-shadow-[0_1px_5px_#60a5fa80] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#60a5fa80] border border-blue-400/40 hover:border-blue-400/50 focus:border-blue-400/50" > <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty @@ -1237,7 +1237,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@user.id} phx-value-type="tip" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-800/10 hover:text-red-300 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-800/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" + class="group bg-red-900/10 text-red-300 transition-colors duration-75 hover:bg-red-800/10 hover:text-red-300 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-800/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-red-400/40 hover:border-red-400/50 focus:border-red-400/50" > <.icon name="tabler-heart" class="size-4 text-current mr-2 -ml-1" /> Tip @@ -1265,7 +1265,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-type="contract" phx-value-marketplace="false" variant="none" - class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract @@ -1366,6 +1366,8 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-value-user_id={@match.user.id} phx-value-type="contract" phx-value-marketplace="true" + variant="none" + class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" > <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract From a7b470d1bfc0cebdaf0a3667cd617e3bdb7cba79 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 19 Apr 2025 12:43:54 +0300 Subject: [PATCH 16/50] add missing forms --- lib/algora_web/live/org/preview_nav.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex index db5009a7c..a90906fad 100644 --- a/lib/algora_web/live/org/preview_nav.ex +++ b/lib/algora_web/live/org/preview_nav.ex @@ -7,6 +7,8 @@ defmodule AlgoraWeb.Org.PreviewNav do alias Algora.Accounts alias Algora.Organizations + alias AlgoraWeb.Forms.BountyForm + alias AlgoraWeb.Forms.ContractForm require Logger @@ -18,7 +20,10 @@ defmodule AlgoraWeb.Org.PreviewNav do {:cont, socket - |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:main_bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + |> assign(:main_bounty_form_open?, false) + |> assign(:main_contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) + |> assign(:main_contract_form_open?, false) |> assign(:current_org, org) |> assign(:current_user, user) |> assign(:current_context, org) @@ -39,7 +44,10 @@ defmodule AlgoraWeb.Org.PreviewNav do socket = socket - |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:main_bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + |> assign(:main_bounty_form_open?, false) + |> assign(:main_contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) + |> assign(:main_contract_form_open?, false) |> assign(:current_org, current_context) |> assign(:current_user_role, :admin) |> assign(:nav, nav_items(repo_owner, repo_name)) From 31c670633bb297453221599c90cf829ae8fdec0d Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 19 Apr 2025 12:57:17 +0300 Subject: [PATCH 17/50] handle drawer events in preview nav --- lib/algora_web/live/org/preview_nav.ex | 32 +++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex index a90906fad..2c43ff5f1 100644 --- a/lib/algora_web/live/org/preview_nav.ex +++ b/lib/algora_web/live/org/preview_nav.ex @@ -31,7 +31,8 @@ defmodule AlgoraWeb.Org.PreviewNav do |> assign(:current_user_role, :admin) |> assign(:nav, nav_items(repo_owner, repo_name)) |> assign(:contacts, []) - |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3) + |> attach_hook(:handle_event, :handle_event, &handle_event/3)} {:error, reason} -> Logger.error("Failed to restore preview state for #{repo_owner}/#{repo_name}: #{inspect(reason)}") @@ -53,6 +54,7 @@ defmodule AlgoraWeb.Org.PreviewNav do |> assign(:nav, nav_items(repo_owner, repo_name)) |> assign(:contacts, []) |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3) + |> attach_hook(:handle_event, :handle_event, &handle_event/3) # checking if the socket is connected to avoid redirect loop that prevents og image from being fetched if (current_context && current_context.last_context == "repo/#{repo_owner}/#{repo_name}") || @@ -97,6 +99,34 @@ defmodule AlgoraWeb.Org.PreviewNav do end end + defp handle_event("create_contract_main", %{"contract_form" => _params}, socket) do + {:cont, put_flash(socket, :warning, "Please sign in to create your contract")} + end + + defp handle_event("create_bounty_main", %{"bounty_form" => _params}, socket) do + {:cont, put_flash(socket, :warning, "Please sign in to create your bounty")} + end + + defp handle_event("open_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, true)} + end + + defp handle_event("close_main_bounty_form", _params, socket) do + {:cont, assign(socket, :main_bounty_form_open?, false)} + end + + defp handle_event("open_main_contract_form", _params, socket) do + {:cont, assign(socket, :main_contract_form_open?, true)} + end + + defp handle_event("close_main_contract_form", _params, socket) do + {:cont, assign(socket, :main_contract_form_open?, false)} + end + + defp handle_event(_event, _params, socket) do + {:cont, socket} + end + defp handle_active_tab_params(_params, _url, socket) do active_tab = case {socket.view, socket.assigns.live_action} do From f5366a10e47b081a883d626b05d36a14975719e3 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 19 Apr 2025 12:57:32 +0300 Subject: [PATCH 18/50] update user layout to place drawers on top level --- .../components/layouts/user.html.heex | 87 ++++++++++--------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index bccd662b5..91f897b41 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -257,27 +257,6 @@ > <.icon name="tabler-user-dollar" class="h-6 w-6 shrink-0" /> - <.drawer - show={@main_contract_form_open?} - direction="right" - on_cancel="close_main_contract_form" - > - <.drawer_header> - <.drawer_title>Create new contract - <.drawer_description> -
Engage a developer for ongoing work
-
- <.icon name="tabler-bulb" class="h-5 w-5 shrink-0" /> - - Weekly contributions, PR review, internship, contract-to-hire - -
- - - <.drawer_content> - - -
<% end %> <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> @@ -292,27 +271,6 @@ class="h-[0.8rem] w-[0.8rem] shrink-0 absolute bottom-[0.2rem] right-[0.2rem]" /> - <.drawer - show={@main_bounty_form_open?} - direction="right" - on_cancel="close_main_bounty_form" - > - <.drawer_header> - <.drawer_title>Create new bounty - <.drawer_description> -
Create and fund a bounty for an issue
-
- <.icon name="tabler-bulb" class="h-5 w-5 shrink-0" /> - - New feature, integration, bug fix, CLI, mobile app, MCP, video - -
- - - <.drawer_content> - - - <% end %> <%!-- {live_render(@socket, AlgoraWeb.Activity.UserNavTimelineLive, @@ -345,6 +303,51 @@ +<%= if @current_user do %> + <%= if main_contract_form = Map.get(assigns, :main_contract_form) do %> + <.drawer + show={@main_contract_form_open?} + direction="right" + on_cancel="close_main_contract_form" + > + <.drawer_header> + <.drawer_title>Create new contract + <.drawer_description> +
Engage a developer for ongoing work
+
+ <.icon name="tabler-bulb" class="h-5 w-5 shrink-0" /> + + Weekly contributions, PR review, internship, contract-to-hire + +
+ + + <.drawer_content> + + + + <% end %> + <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> + <.drawer show={@main_bounty_form_open?} direction="right" on_cancel="close_main_bounty_form"> + <.drawer_header> + <.drawer_title>Create new bounty + <.drawer_description> +
Create and fund a bounty for an issue
+
+ <.icon name="tabler-bulb" class="h-5 w-5 shrink-0" /> + + New feature, integration, bug fix, CLI, mobile app, MCP, video + +
+ + + <.drawer_content> + + + + <% end %> +<% end %> +
<.flash_group flash={@flash} /> From 0f120cb7a498391dec743d5dacfb82df03761eba Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 19 Apr 2025 13:57:44 +0300 Subject: [PATCH 19/50] fix og --- lib/algora_web/live/org/dashboard_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index dcae2b498..231ca1e3a 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1181,7 +1181,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
<.link :if={@user.provider_login} From b39f1a72c72b0a6231cd220245798ca96dea609a Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 19 Apr 2025 19:32:17 +0300 Subject: [PATCH 20/50] remove unused vars --- lib/algora_web/components/layouts/user.html.heex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index 91f897b41..f265f795d 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -249,7 +249,7 @@ <%= if @current_user do %>
- <%= if main_contract_form = Map.get(assigns, :main_contract_form) do %> + <%= if Map.get(assigns, :main_contract_form) do %>
<.button phx-click="open_main_contract_form" @@ -259,7 +259,7 @@
<% end %> - <%= if main_bounty_form = Map.get(assigns, :main_bounty_form) do %> + <%= if Map.get(assigns, :main_bounty_form) do %>
<.button phx-click="open_main_bounty_form" From 6611aa438f81b27366008d2b1db86156d2a620bb Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 22 Apr 2025 15:45:51 +0300 Subject: [PATCH 21/50] update match card to show total cost per week --- lib/algora_web/live/org/dashboard_live.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 231ca1e3a..aa98f0d7d 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1353,7 +1353,10 @@ defmodule AlgoraWeb.Org.DashboardLive do class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground sm:text-sm" > - {Money.to_string!(@match[:hourly_rate])}/hr + {@match[:hourly_rate] + |> Money.mult!(@match.user.hours_per_week || 30) + |> Money.mult!(Decimal.new("1.13")) + |> Money.to_string!()}/wk
From 7fba6ab5756c177e219d57df022385bbd729637a Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 22 Apr 2025 16:44:02 +0300 Subject: [PATCH 22/50] misc improvements --- lib/algora_web/live/org/dashboard_live.ex | 181 +++++++++++++--------- 1 file changed, 104 insertions(+), 77 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index aa98f0d7d..cd232d5ac 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -320,7 +320,7 @@ defmodule AlgoraWeb.Org.DashboardLive do title={"#{header_prefix(@previewed_user)} Contributors"} subtitle="Share bounties, tips or contract opportunities with your top contributors" > -
+
<%= for %Contributor{user: user} <- @contributors do %> @@ -344,9 +344,10 @@ defmodule AlgoraWeb.Org.DashboardLive do <.getting_started id="getting_started_main" achievements={ - if incomplete?(@achievements, :complete_signin_status), - do: @achievements |> Enum.take(1), - else: @achievements + if incomplete?(@achievements, :complete_signin_status) or + incomplete?(@achievements, :complete_signup_status), + do: @achievements |> Enum.take(1), + else: @achievements } current_user={@current_user} current_org={@current_org} @@ -1297,86 +1298,107 @@ defmodule AlgoraWeb.Org.DashboardLive do defp match_card(assigns) do ~H"""
-
-
- <.link navigate={User.url(@match.user)}> - <.avatar class="h-16 w-16 rounded-full"> - <.avatar_image src={@match.user.avatar_url} alt={@match.user.name} /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@match.user.name)} - - - +
+
+
+ <.link navigate={User.url(@match.user)}> + <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={@match.user.avatar_url} alt={@match.user.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@match.user.name)} + + + -
-
- <.link - navigate={User.url(@match.user)} - class="text-base sm:text-lg font-semibold hover:underline" - > - {@match.user.name} {Algora.Misc.CountryEmojis.get(@match.user.country)} - - <.badge - :if={@match.badge_text} - variant={@match.badge_variant} - size="lg" - class="shrink-0 absolute top-0 left-0" - > - {@match.badge_text} - -
-
- <.link - :if={@match.user.provider_login} - href={"https://github.com/#{@match.user.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" +
+
+ <.link + navigate={User.url(@match.user)} + class="text-base sm:text-lg font-semibold hover:underline" + > + {@match.user.name} {Algora.Misc.CountryEmojis.get(@match.user.country)} + + <.badge + :if={@match.badge_text} + variant={@match.badge_variant} + size="lg" + class="shrink-0 absolute top-0 left-0" + > + {@match.badge_text} + +
+
- - {@match.user.provider_login} - - <.link - :if={@match.user.provider_meta["twitter_handle"]} - href={"https://x.com/#{@match.user.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" + <.link + :if={@match.user.provider_login} + href={"https://github.com/#{@match.user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@match.user.provider_login} + + <.link + :if={@match.user.provider_meta["twitter_handle"]} + href={"https://x.com/#{@match.user.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="shrink-0 h-4 w-4" /> + {@match.user.provider_meta["twitter_handle"]} + +
+ <%!--
- <.icon name="tabler-brand-x" class="shrink-0 h-4 w-4" /> - {@match.user.provider_meta["twitter_handle"]} - -
-
- - {@match[:hourly_rate] - |> Money.mult!(@match.user.hours_per_week || 30) - |> Money.mult!(Decimal.new("1.13")) - |> Money.to_string!()}/wk - + + {@match[:hourly_rate] + |> Money.mult!(@match.user.hours_per_week || 30) + |> Money.mult!(Decimal.new("1.13")) + |> Money.to_string!()}/wk + +
--%>
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@match.user.id} + phx-value-type="contract" + phx-value-marketplace="true" + variant="none" + class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" + > + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract +
+
+
+
+ Total payment for {@match.user.hours_per_week || 30} + hours + + ({@match.user.name}'s availability) + +
+ (includes all platform and payment processing fees) +
+
+
+ {Money.to_string!( + Money.mult!( + @match[:hourly_rate] |> Money.mult!(@match.user.hours_per_week || 30), + Decimal.new("1.13") + ) + )} +
+
+
-
- <.button - phx-click="share_opportunity" - phx-value-user_id={@match.user.id} - phx-value-type="contract" - phx-value-marketplace="true" - variant="none" - class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" - > - <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract - -
- -
+
Completed @@ -1538,7 +1560,12 @@ defmodule AlgoraWeb.Org.DashboardLive do :if={length(@achievements) > 1} id="getting_started_sidebar" class="pb-12" - achievements={@achievements} + achievements={ + if incomplete?(@achievements, :complete_signin_status) or + incomplete?(@achievements, :complete_signup_status), + do: @achievements |> Enum.take(1), + else: @achievements + } current_user={@current_user} current_org={@current_org} secret={@secret} From 0212f7d60ff8785e934ce04ad6a7de2d051837b5 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 22 Apr 2025 18:41:21 +0300 Subject: [PATCH 23/50] in midst of updating matches section --- lib/algora_web/live/org/dashboard_live.ex | 104 +++++++++++++--------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index cd232d5ac..d27045190 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -365,6 +365,23 @@ defmodule AlgoraWeb.Org.DashboardLive do subtitle="Top 1% Algora developers in your tech stack available to hire now" >
+
+

How it works

+
    +
  • + <.icon name="tabler-circle-number-1 mr-2" class="size-6 text-success-400 shrink-0" /> + Authorize payment to offer contract +
  • +
  • + <.icon name="tabler-circle-number-2 mr-2" class="size-6 text-success-400 shrink-0" /> + Escrowed when developer accepts +
  • +
  • + <.icon name="tabler-circle-number-3 mr-2" class="size-6 text-success-400 shrink-0" /> + Release or withhold escrow end of week +
  • +
+
<%= for match <- @matches do %> <.match_card match={match} @@ -1297,8 +1314,8 @@ defmodule AlgoraWeb.Org.DashboardLive do defp match_card(assigns) do ~H""" -
-
+
+
<.link navigate={User.url(@match.user)}> @@ -1363,45 +1380,10 @@ defmodule AlgoraWeb.Org.DashboardLive do
--%>
- <.button - phx-click="share_opportunity" - phx-value-user_id={@match.user.id} - phx-value-type="contract" - phx-value-marketplace="true" - variant="none" - class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" - > - <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract -
-
-
-
- Total payment for {@match.user.hours_per_week || 30} - hours - - ({@match.user.name}'s availability) - -
- (includes all platform and payment processing fees) -
-
-
- {Money.to_string!( - Money.mult!( - @match[:hourly_rate] |> Money.mult!(@match.user.hours_per_week || 30), - Decimal.new("1.13") - ) - )} -
-
-
-
- -
-
+
Completed - + {@match.user.transactions_count} {ngettext( "bounty", @@ -1410,7 +1392,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )} across - + {ngettext( "%{count} project", "%{count} projects", @@ -1431,7 +1413,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
-
+
{project.name}
@@ -1442,7 +1424,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )}
- + {total_earned} awarded @@ -1453,6 +1435,44 @@ defmodule AlgoraWeb.Org.DashboardLive do <% end %>
+ +
+
+
+
+ Total payment for {@match.user.hours_per_week || 30} + hours +
+ (includes all platform and payment processing fees) +
+
+
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@match.user.id} + phx-value-type="contract" + phx-value-marketplace="true" + variant="none" + class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50 h-full py-4" + size="xl" + > +
+ Offer contract +
+ {Money.to_string!( + Money.mult!( + @match[:hourly_rate] |> Money.mult!(@match.user.hours_per_week || 30), + Decimal.new("1.13") + ), + no_fraction_if_integer: false + )} / week +
+
+ +
+
+
+
""" end From 577a7d84cfeb8db8f3cf4acfc5f4404510f2b9f2 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 22 Apr 2025 19:09:18 +0300 Subject: [PATCH 24/50] misc improvements --- lib/algora_web/live/org/dashboard_live.ex | 24 ++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index d27045190..aead8e067 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -63,7 +63,7 @@ defmodule AlgoraWeb.Org.DashboardLive do end @impl true - def mount(_params, _session, socket) do + def mount(params, _session, socket) do %{current_org: current_org} = socket.assigns if Member.can_create_bounty?(socket.assigns.current_user_role) do @@ -92,6 +92,12 @@ defmodule AlgoraWeb.Org.DashboardLive do {:ok, socket + |> assign(:page_title, "#{header_prefix(current_org)}") + |> assign( + :page_description, + "Share bounties, tips or contracts with #{header_prefix(current_org)} contributors and Algora matches" + ) + |> assign(:screenshot?, not is_nil(params["screenshot"])) |> assign(:ip_address, AlgoraWeb.Util.get_ip(socket)) |> assign(:admins_last_active, admins_last_active) |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) @@ -365,20 +371,20 @@ defmodule AlgoraWeb.Org.DashboardLive do subtitle="Top 1% Algora developers in your tech stack available to hire now" >
-
-

How it works

-
    -
  • +
    +

    How it works

    +
      +
    • <.icon name="tabler-circle-number-1 mr-2" class="size-6 text-success-400 shrink-0" /> - Authorize payment to offer contract + Authorize payment to send offer
    • -
    • +
    • <.icon name="tabler-circle-number-2 mr-2" class="size-6 text-success-400 shrink-0" /> Escrowed when developer accepts
    • -
    • +
    • <.icon name="tabler-circle-number-3 mr-2" class="size-6 text-success-400 shrink-0" /> - Release or withhold escrow end of week + Release/withhold escrow end of week
    From f05492e6dd98d379cd2d4b0ade0cdcdc75132a2d Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 00:18:57 +0300 Subject: [PATCH 25/50] allow any payment at any time after initial contract cycle --- lib/algora_web/live/contract_live.ex | 144 ++++++++++++++++++++++----- 1 file changed, 120 insertions(+), 24 deletions(-) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index ebdb5dd87..f9f913693 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -13,12 +13,13 @@ defmodule AlgoraWeb.ContractLive do alias Algora.Organizations.Member alias Algora.Payments alias Algora.Repo + alias Algora.Types.USD alias Algora.Util alias Algora.Workspace require Logger - # defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] + defp tip_options, do: [{"None", 0}, {"10%", 10}, {"20%", 20}, {"50%", 50}] defmodule RewardBountyForm do @moduledoc false @@ -28,7 +29,7 @@ defmodule AlgoraWeb.ContractLive do @primary_key false embedded_schema do - field :amount, Algora.Types.USD + field :amount, USD field :tip_percentage, :decimal end @@ -108,8 +109,7 @@ defmodule AlgoraWeb.ContractLive do ticket_body_html = Algora.Markdown.render(bounty.ticket.description) - reward_changeset = - RewardBountyForm.changeset(%RewardBountyForm{}, %{amount: bounty.amount}) + reward_changeset = RewardBountyForm.changeset(%RewardBountyForm{}, %{amount: bounty.amount, tip_percentage: 0}) {:ok, thread} = Chat.get_or_create_bounty_thread(bounty) messages = thread.id |> Chat.list_messages() |> Repo.preload(:sender) @@ -138,6 +138,7 @@ defmodule AlgoraWeb.ContractLive do |> assign(:total_paid, total_paid) |> assign(:ticket_body_html, ticket_body_html) |> assign(:show_reward_modal, false) + |> assign(:show_authorize_modal, false) |> assign(:selected_context, nil) |> assign(:line_items, []) |> assign(:thread, thread) @@ -146,7 +147,7 @@ defmodule AlgoraWeb.ContractLive do |> assign(:reward_form, to_form(reward_changeset)) |> assign_contractor(bounty.shared_with) |> assign_transactions() - |> assign_line_items()} + |> assign_line_items(reward_changeset)} end @impl true @@ -200,6 +201,11 @@ defmodule AlgoraWeb.ContractLive do {:noreply, assign(socket, :show_reward_modal, true)} end + @impl true + def handle_event("authorize", _params, socket) do + {:noreply, assign(socket, :show_authorize_modal, true)} + end + @impl true def handle_event("close_drawer", _params, socket) do {:noreply, close_drawers(socket)} @@ -207,15 +213,12 @@ defmodule AlgoraWeb.ContractLive do @impl true def handle_event("validate_reward", %{"reward_bounty_form" => params}, socket) do + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params) + {:noreply, socket - |> assign(:reward_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params))) - |> assign_line_items()} - end - - @impl true - def handle_event("assign_line_items", %{"reward_bounty_form" => _params}, socket) do - {:noreply, assign_line_items(socket)} + |> assign(:reward_form, to_form(changeset)) + |> assign_line_items(changeset)} end @impl true @@ -345,9 +348,11 @@ defmodule AlgoraWeb.ContractLive do <% end %> <%= if _transaction = Enum.find(@transactions, fn tx -> tx.type == :debit end) do %> - <.badge variant="success" class="mb-auto"> - Active - +
    + <.badge variant="success" class="mb-auto"> + Active + +
    <% end %>
@@ -404,7 +409,7 @@ defmodule AlgoraWeb.ContractLive do
- <.button phx-click="reward"> + <.button phx-click="authorize"> Authorize @@ -414,7 +419,16 @@ defmodule AlgoraWeb.ContractLive do <.card :if={length(@transactions) > 0}> <.card_header> <.card_title> - Timeline +
+ Timeline + <%= if @can_create_bounty do %> + <%= if _transaction = Enum.find(@transactions, fn tx -> tx.type == :debit and tx.status == :succeeded end) do %> + <.button phx-click="reward"> + Make payment + + <% end %> + <% end %> +
<.card_content class="pt-0"> @@ -589,7 +603,7 @@ defmodule AlgoraWeb.ContractLive do
- <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> + <.drawer :if={@current_user} show={@show_authorize_modal} on_cancel="close_drawer"> <.drawer_header> <.drawer_title>Authorize payment <.drawer_description> @@ -612,8 +626,88 @@ defmodule AlgoraWeb.ContractLive do field={@reward_form[:amount]} disabled /> +
+ + + <.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"> + Authorize with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + <.drawer :if={@current_user} show={@show_reward_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Pay contract + <.drawer_description> + You can pay any amount at any time. + + + <.drawer_content class="mt-4"> + <.form for={@reward_form} phx-change="validate_reward" phx-submit="pay_with_stripe"> +
+
+ <.card> + <.card_header> + <.card_title>Payment Details + + <.card_content class="pt-0"> +
+ <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_form[:amount]} + /> - <%!--
+
<.label>Tip
<.radio_group @@ -622,7 +716,7 @@ defmodule AlgoraWeb.ContractLive do options={tip_options()} />
-
--%> +
@@ -674,7 +768,7 @@ defmodule AlgoraWeb.ContractLive do Cancel <.button type="submit"> - Authorize with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
@@ -684,12 +778,12 @@ defmodule AlgoraWeb.ContractLive do """ end - defp assign_line_items(socket) do + defp assign_line_items(socket, changeset) do line_items = Bounties.generate_line_items( %{ owner: socket.assigns.bounty.owner, - amount: calculate_final_amount(socket.assigns.reward_form.source) + amount: calculate_final_amount(changeset) }, bounty: socket.assigns.bounty, ticket_ref: socket.assigns.ticket_ref, @@ -728,7 +822,9 @@ defmodule AlgoraWeb.ContractLive do end defp close_drawers(socket) do - assign(socket, :show_reward_modal, false) + socket + |> assign(:show_reward_modal, false) + |> assign(:show_authorize_modal, false) end defp assign_contractor(socket, shared_with) do From 069304167878a8083eb03d2420aaec9077ea2b84 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 16:55:44 +0300 Subject: [PATCH 26/50] feat: optimize go page for screenshot --- lib/algora_web/live/org/dashboard_live.ex | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index aead8e067..ff46a13ad 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -200,7 +200,7 @@ defmodule AlgoraWeb.Org.DashboardLive do def render(assigns) do ~H"""
-
+
<.section :if={@payable_bounties != %{}}> <.card> <.card_header> @@ -371,7 +371,7 @@ defmodule AlgoraWeb.Org.DashboardLive do subtitle="Top 1% Algora developers in your tech stack available to hire now" >
-
+

How it works

  • @@ -1320,8 +1320,8 @@ defmodule AlgoraWeb.Org.DashboardLive do defp match_card(assigns) do ~H""" -
    -
    +
    +
    <.link navigate={User.url(@match.user)}> @@ -1406,7 +1406,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )}
    -
    +
    <%= for {project, total_earned} <- @match.projects |> Enum.take(2) do %> <.link navigate={User.url(project)} @@ -1442,8 +1442,8 @@ defmodule AlgoraWeb.Org.DashboardLive do
    -
    -
    +
    +
    Total payment for {@match.user.hours_per_week || 30} From 18cf103d788f3679658fec9be72a42b12db15d80 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 19:52:28 +0300 Subject: [PATCH 27/50] add make payment btn on initial contract page --- lib/algora_web/live/contract_live.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index f9f913693..6a3e56c23 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -354,6 +354,12 @@ defmodule AlgoraWeb.ContractLive do
    <% end %> + + <%= if @can_create_bounty && @transactions == [] do %> + <.button phx-click="reward"> + Make payment + + <% end %>
    From 79e46f5640437651a14376eb1d8f82d286d46916 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 19:53:06 +0300 Subject: [PATCH 28/50] use manual flow for contract bounties --- lib/algora/payments/payments.ex | 57 +++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index bd128df81..47a4cb3b4 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -5,11 +5,11 @@ defmodule Algora.Payments do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Admin alias Algora.Bounties alias Algora.Bounties.Bounty alias Algora.Bounties.Claim alias Algora.Bounties.Tip - alias Algora.Contracts.Contract alias Algora.MoneyUtils alias Algora.Payments.Account alias Algora.Payments.Customer @@ -617,7 +617,7 @@ defmodule Algora.Payments do {_, txs} = Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), set: [ - status: :succeeded, + status: :processing, succeeded_at: DateTime.utc_now(), provider: "stripe", provider_id: charge_id, @@ -627,18 +627,61 @@ defmodule Algora.Payments do ) bounty_ids = txs |> Enum.map(& &1.bounty_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() + + bounties = + from(b in Bounty, + where: b.id in ^bounty_ids, + join: u in assoc(b, :owner), + join: t in assoc(b, :ticket), + select: %{b | ticket: t, owner: u} + ) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + + {issue_bounty_ids, contract_bounty_ids} = + Enum.split_with(bounty_ids, fn id -> + bounty = bounties[id] + bounty && bounty.ticket.repository_id + end) + tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - contract_ids = txs |> Enum.map(& &1.contract_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - Repo.update_all(from(b in Bounty, where: b.id in ^bounty_ids), set: [status: :paid]) + Repo.update_all(from(b in Bounty, where: b.id in ^issue_bounty_ids), set: [status: :paid]) Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) - Repo.update_all(from(c in Contract, where: c.id in ^contract_ids), set: [status: :paid]) # TODO: add and use a new "paid" status for claims Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) + auto_txs = + Enum.filter(txs, fn tx -> + bounty = bounties[tx.bounty_id] + + contract? = tx.bounty_id in contract_bounty_ids + + if contract? do + Admin.alert( + "Contract payment received. URL: #{AlgoraWeb.Endpoint.url()}/#{bounty.owner.handle}/contracts/#{bounty.id}", + :info + ) + end + + tx.type != :credit or not contract? + end) + + Repo.update_all( + from(t in Transaction, where: t.group_id == ^group_id and t.id in ^Enum.map(auto_txs, & &1.id), select: t), + set: [ + status: :succeeded, + succeeded_at: DateTime.utc_now(), + provider: "stripe", + provider_id: charge_id, + provider_charge_id: charge_id, + provider_payment_intent_id: payment_intent_id + ] + ) + activities_result = - txs + auto_txs |> Enum.filter(&(&1.type == :credit)) |> Enum.reduce_while(:ok, fn tx, :ok -> case Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]}) do @@ -648,7 +691,7 @@ defmodule Algora.Payments do end) jobs_result = - txs + auto_txs |> Enum.filter(&(&1.type == :credit)) |> Enum.reduce_while(:ok, fn credit, :ok -> case fetch_active_account(credit.user_id) do From a076dfaa2c1fa494ce1a8badc5efce8ec3150bbf Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 19:53:14 +0300 Subject: [PATCH 29/50] log all admin alerts --- lib/algora/admin/admin.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index dbd3acd4b..40ad54c4e 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -396,6 +396,8 @@ defmodule Algora.Admin do end def alert(message, severity) do + Logger.info(message) + %{ payload: %{ embeds: [ From 35255a486ba4df33d1cdc4f0ccb15365723f0a7d Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 20:45:23 +0300 Subject: [PATCH 30/50] feat: add contract type to bounties and update related forms and schemas --- lib/algora/bounties/bounties.ex | 18 ++++++++++------ lib/algora/bounties/schemas/bounty.ex | 14 ++++++++++++- lib/algora_web/forms/contract_form.ex | 21 ++++++++++++++----- lib/algora_web/live/org/dashboard_live.ex | 20 ++++++++++++++---- lib/algora_web/live/org/nav.ex | 3 ++- ...23171814_add_contract_type_to_bounties.exs | 9 ++++++++ 6 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 priv/repo/migrations/20250423171814_add_contract_type_to_bounties.exs diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 300eb9561..cedd71e83 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -53,7 +53,8 @@ defmodule Algora.Bounties do visibility: Bounty.visibility(), shared_with: [String.t()], hours_per_week: integer() | nil, - hourly_rate: Money.t() | 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 @@ -66,7 +67,8 @@ defmodule Algora.Bounties do visibility: params[:visibility] || owner.bounty_mode, shared_with: params[:shared_with] || [], hours_per_week: params[:hours_per_week], - hourly_rate: params[:hourly_rate] + hourly_rate: params[:hourly_rate], + contract_type: params[:contract_type] }) changeset @@ -115,7 +117,8 @@ defmodule Algora.Bounties do visibility: Bounty.visibility() | nil, shared_with: [String.t()] | nil, hourly_rate: Money.t() | nil, - hours_per_week: integer() | nil + hours_per_week: integer() | nil, + contract_type: Bounty.contract_type() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -148,7 +151,8 @@ defmodule Algora.Bounties do visibility: opts[:visibility], shared_with: shared_with, hourly_rate: opts[:hourly_rate], - hours_per_week: opts[:hours_per_week] + hours_per_week: opts[:hours_per_week], + contract_type: opts[:contract_type] }) :set -> @@ -200,7 +204,8 @@ defmodule Algora.Bounties do visibility: Bounty.visibility() | nil, shared_with: [String.t()] | nil, hours_per_week: integer() | nil, - hourly_rate: Money.t() | nil + hourly_rate: Money.t() | nil, + contract_type: Bounty.contract_type() | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -221,7 +226,8 @@ defmodule Algora.Bounties do visibility: opts[:visibility], shared_with: shared_with, hours_per_week: opts[:hours_per_week], - hourly_rate: opts[:hourly_rate] + hourly_rate: opts[:hourly_rate], + contract_type: opts[:contract_type] }), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do broadcast() diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index b66d28566..4749b471e 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -7,6 +7,7 @@ defmodule Algora.Bounties.Bounty do alias Algora.Types.Money @type visibility :: :community | :exclusive | :public + @type contract_type :: :bring_your_own | :marketplace typed_schema "bounties" do field :amount, Money @@ -14,6 +15,7 @@ defmodule Algora.Bounties.Bounty do 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 @@ -36,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, :hourly_rate]) + |> 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_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index a7ffff874..becf48c8d 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -13,7 +13,7 @@ defmodule AlgoraWeb.Forms.ContractForm do field :amount, USD field :hourly_rate, USD field :hours_per_week, :integer - field :marketplace?, :boolean, default: false + field :contract_type, Ecto.Enum, values: [:bring_your_own, :marketplace], default: :bring_your_own field :type, Ecto.Enum, values: [:fixed, :hourly], default: :fixed field :title, :string field :description, :string @@ -28,7 +28,16 @@ defmodule AlgoraWeb.Forms.ContractForm do def changeset(form, attrs) do form - |> cast(attrs, [:amount, :hourly_rate, :hours_per_week, :type, :title, :description, :contractor_handle]) + |> cast(attrs, [ + :amount, + :hourly_rate, + :hours_per_week, + :type, + :title, + :description, + :contractor_handle, + :contract_type + ]) |> validate_required([:contractor_handle]) |> validate_type_fields() |> Validations.validate_github_handle(:contractor_handle, :contractor) @@ -57,7 +66,9 @@ defmodule AlgoraWeb.Forms.ContractForm do phx-change="validate_contract_main" >
    - <%= if get_field(@form.source, :marketplace?) do %> + <.input type="hidden" field={@form[:contract_type]} /> + + <%= if get_field(@form.source, :contract_type) == :marketplace do %> <%= if contractor = get_field(@form.source, :contractor) do %> <.card> <.card_content> @@ -129,7 +140,7 @@ defmodule AlgoraWeb.Forms.ContractForm do <.input label="Title" field={@form[:title]} /> <.input label="Description (optional)" field={@form[:description]} type="textarea" /> - <%= if not get_field(@form.source, :marketplace?) do %> + <%= if get_field(@form.source, :contract_type) == :bring_your_own do %>
    <% end %> - <%= if get_field(@form.source, :marketplace?) do %> + <%= if get_field(@form.source, :contract_type) == :marketplace do %> <.input type="hidden" field={@form[:amount]} /> <.input type="hidden" field={@form[:hourly_rate]} /> <.input type="hidden" field={@form[:hours_per_week]} /> diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index ff46a13ad..0fa1f4dfd 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -333,6 +333,12 @@ defmodule AlgoraWeb.Org.DashboardLive do <.developer_card user={user} contract_for_user={contract_for_user(@contracts, user)} + contract_type={ + if(Enum.find(@matches, &(&1.user.id == user.id)), + do: "marketplace", + else: "bring_your_own" + ) + } current_org={@current_org} /> <% end %> @@ -410,6 +416,12 @@ defmodule AlgoraWeb.Org.DashboardLive do <.developer_card user={user} contract_for_user={contract_for_user(@contracts, user)} + contract_type={ + if(Enum.find(@matches, &(&1.user.id == user.id)), + do: "marketplace", + else: "bring_your_own" + ) + } current_org={@current_org} /> <% end %> @@ -658,7 +670,7 @@ defmodule AlgoraWeb.Org.DashboardLive do @impl true def handle_event( "share_opportunity", - %{"user_id" => user_id, "type" => "contract", "marketplace" => marketplace?}, + %{"user_id" => user_id, "type" => "contract", "contract_type" => contract_type}, socket ) do developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) @@ -673,7 +685,7 @@ defmodule AlgoraWeb.Org.DashboardLive do |> assign( :main_contract_form, %ContractForm{ - marketplace?: marketplace? == "true", + contract_type: String.to_existing_atom(contract_type), contractor: match[:user] || developer } |> ContractForm.changeset(%{ @@ -1287,7 +1299,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="contract" - phx-value-marketplace="false" + phx-value-contract_type={@contract_type} variant="none" class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50" > @@ -1457,7 +1469,7 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-click="share_opportunity" phx-value-user_id={@match.user.id} phx-value-type="contract" - phx-value-marketplace="true" + phx-value-contract_type="marketplace" variant="none" class="group bg-emerald-900/10 text-emerald-300 transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-300 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/40 hover:border-emerald-400/50 focus:border-emerald-400/50 h-full py-4" size="xl" diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index de25bc8f0..0e16a40c3 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -141,7 +141,8 @@ defmodule AlgoraWeb.Org.Nav do hourly_rate: data.hourly_rate, hours_per_week: data.hours_per_week, shared_with: [data.contractor.provider_id], - visibility: :exclusive + visibility: :exclusive, + contract_type: data.contract_type ) case bounty_res do diff --git a/priv/repo/migrations/20250423171814_add_contract_type_to_bounties.exs b/priv/repo/migrations/20250423171814_add_contract_type_to_bounties.exs new file mode 100644 index 000000000..8a996e336 --- /dev/null +++ b/priv/repo/migrations/20250423171814_add_contract_type_to_bounties.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddContractTypeToBounties do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :contract_type, :string + end + end +end From a70ac0572a8afc5380cb4e07ef62d68b04641f13 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 21:06:44 +0300 Subject: [PATCH 31/50] simplify code --- lib/algora/payments/payments.ex | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 47a4cb3b4..90df17a96 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -618,7 +618,6 @@ defmodule Algora.Payments do Repo.update_all(from(t in Transaction, where: t.group_id == ^group_id, select: t), set: [ status: :processing, - succeeded_at: DateTime.utc_now(), provider: "stripe", provider_id: charge_id, provider_charge_id: charge_id, @@ -670,14 +669,7 @@ defmodule Algora.Payments do Repo.update_all( from(t in Transaction, where: t.group_id == ^group_id and t.id in ^Enum.map(auto_txs, & &1.id), select: t), - set: [ - status: :succeeded, - succeeded_at: DateTime.utc_now(), - provider: "stripe", - provider_id: charge_id, - provider_charge_id: charge_id, - provider_payment_intent_id: payment_intent_id - ] + set: [status: :succeeded, succeeded_at: DateTime.utc_now()] ) activities_result = From fccdb0262adc9be3bb79f2a7cc1c460166de0a12 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 21:06:56 +0300 Subject: [PATCH 32/50] add admin fn to release payment --- lib/algora/admin/admin.ex | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 40ad54c4e..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 From 93f47b5dcd2f2a52a9e4ed510afa87d23bccf097 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 22:01:03 +0300 Subject: [PATCH 33/50] adapt line items based on contract type --- lib/algora/bounties/bounties.ex | 76 ++++++++++++++++------------ lib/algora_web/live/contract_live.ex | 3 +- 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index cedd71e83..3ab1c1b2a 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -828,7 +828,8 @@ defmodule Algora.Bounties do bounty: Bounty.t(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, claims: [Claim.t()], - recipient: User.t() + recipient: User.t(), + contract_type: Bounty.contract_type() ] ) :: [LineItem.t()] @@ -841,7 +842,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) @@ -849,43 +850,54 @@ defmodule Algora.Bounties do transaction_fee_pct = Payments.get_transaction_fee_pct() - payouts = - if recipient do + case opts[:contract_type] do + :marketplace -> [ %LineItem{ - amount: amount, - title: "Payment to @#{recipient.provider_login}", - description: description, + amount: Money.mult!(amount, Decimal.add(1, Decimal.add(platform_fee_pct, transaction_fee_pct))), + 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 @spec create_payment_session( diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 6a3e56c23..5e7f184f6 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -793,7 +793,8 @@ defmodule AlgoraWeb.ContractLive do }, bounty: socket.assigns.bounty, ticket_ref: socket.assigns.ticket_ref, - recipient: socket.assigns.contractor + recipient: socket.assigns.contractor, + contract_type: socket.assigns.bounty.contract_type ) assign(socket, :line_items, line_items) From 3bb436608d1bacc13467db080ae5bb04eb79f6d1 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 22:09:38 +0300 Subject: [PATCH 34/50] remove redundant param --- lib/algora/bounties/bounties.ex | 7 +++---- lib/algora_web/live/contract_live.ex | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 3ab1c1b2a..42a3a3925 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -828,8 +828,7 @@ defmodule Algora.Bounties do bounty: Bounty.t(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, claims: [Claim.t()], - recipient: User.t(), - contract_type: Bounty.contract_type() + recipient: User.t() ] ) :: [LineItem.t()] @@ -850,8 +849,8 @@ defmodule Algora.Bounties do transaction_fee_pct = Payments.get_transaction_fee_pct() - case opts[:contract_type] do - :marketplace -> + case opts[:bounty] do + %{contract_type: :marketplace} -> [ %LineItem{ amount: Money.mult!(amount, Decimal.add(1, Decimal.add(platform_fee_pct, transaction_fee_pct))), diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 5e7f184f6..6a3e56c23 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -793,8 +793,7 @@ defmodule AlgoraWeb.ContractLive do }, bounty: socket.assigns.bounty, ticket_ref: socket.assigns.ticket_ref, - recipient: socket.assigns.contractor, - contract_type: socket.assigns.bounty.contract_type + recipient: socket.assigns.contractor ) assign(socket, :line_items, line_items) From a9a970f4506e51f5ee6a8f1a84eb546838c01ccf Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 22:09:48 +0300 Subject: [PATCH 35/50] redirect user back to contrat upon payment --- lib/algora/bounties/bounties.ex | 11 +++++++++-- lib/algora_web/live/contract_live.ex | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 42a3a3925..05bc1d98f 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -781,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 @@ -790,7 +795,9 @@ 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 diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 6a3e56c23..811ca54a0 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -805,7 +805,9 @@ defmodule AlgoraWeb.ContractLive do Bounties.reward_bounty( %{owner: bounty.owner, amount: final_amount, bounty: bounty, claims: []}, ticket_ref: socket.assigns.ticket_ref, - recipient: socket.assigns.contractor + recipient: socket.assigns.contractor, + success_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}"), + cancel_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}") ) end From 6291cae6b64ff6c2708ac349bc5a390a568a02c8 Mon Sep 17 00:00:00 2001 From: zafer Date: Wed, 23 Apr 2025 22:54:39 +0300 Subject: [PATCH 36/50] misc contract calc --- lib/algora/bounties/bounties.ex | 9 +++++++-- lib/algora/contracts/contracts.ex | 1 - lib/algora/payments/payments.ex | 12 ++++++------ lib/algora_web/forms/contract_form.ex | 5 ++++- lib/algora_web/live/contract_live.ex | 8 ++++++-- lib/algora_web/live/org/dashboard_live.ex | 22 ++++------------------ lib/algora_web/live/org/nav.ex | 6 +++++- 7 files changed, 32 insertions(+), 31 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 05bc1d98f..0ec676b1d 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -860,7 +860,7 @@ defmodule Algora.Bounties do %{contract_type: :marketplace} -> [ %LineItem{ - amount: Money.mult!(amount, Decimal.add(1, Decimal.add(platform_fee_pct, transaction_fee_pct))), + amount: amount, title: "Contract payment - @#{recipient.provider_login}", description: "(includes all platform and payment processing fees)", image: recipient.avatar_url, @@ -906,6 +906,12 @@ 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) + @spec create_payment_session( %{owner: User.t(), amount: Money.t(), description: String.t()}, opts :: [ @@ -1088,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/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/payments/payments.ex b/lib/algora/payments/payments.ex index 90df17a96..ec4514065 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -637,16 +637,16 @@ defmodule Algora.Payments do |> Repo.all() |> Map.new(&{&1.id, &1}) - {issue_bounty_ids, contract_bounty_ids} = + {auto_bounty_ids, manual_bounty_ids} = Enum.split_with(bounty_ids, fn id -> bounty = bounties[id] - bounty && bounty.ticket.repository_id + bounty && bounty.contract_type != :marketplace end) tip_ids = txs |> Enum.map(& &1.tip_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() claim_ids = txs |> Enum.map(& &1.claim_id) |> Enum.reject(&is_nil/1) |> Enum.uniq() - Repo.update_all(from(b in Bounty, where: b.id in ^issue_bounty_ids), set: [status: :paid]) + Repo.update_all(from(b in Bounty, where: b.id in ^auto_bounty_ids), set: [status: :paid]) Repo.update_all(from(t in Tip, where: t.id in ^tip_ids), set: [status: :paid]) # TODO: add and use a new "paid" status for claims Repo.update_all(from(c in Claim, where: c.id in ^claim_ids), set: [status: :approved]) @@ -655,16 +655,16 @@ defmodule Algora.Payments do Enum.filter(txs, fn tx -> bounty = bounties[tx.bounty_id] - contract? = tx.bounty_id in contract_bounty_ids + manual? = tx.bounty_id in manual_bounty_ids - if contract? do + if tx.type == :credit and manual? do Admin.alert( "Contract payment received. URL: #{AlgoraWeb.Endpoint.url()}/#{bounty.owner.handle}/contracts/#{bounty.id}", :info ) end - tx.type != :credit or not contract? + tx.type != :credit or not manual? end) Repo.update_all( diff --git a/lib/algora_web/forms/contract_form.ex b/lib/algora_web/forms/contract_form.ex index becf48c8d..dd4aa8daf 100644 --- a/lib/algora_web/forms/contract_form.ex +++ b/lib/algora_web/forms/contract_form.ex @@ -6,6 +6,7 @@ defmodule AlgoraWeb.Forms.ContractForm do import Ecto.Changeset alias Algora.Accounts.User + alias Algora.Bounties alias Algora.Types.USD alias Algora.Validations @@ -224,7 +225,9 @@ defmodule AlgoraWeb.Forms.ContractForm do
    - {Money.to_string!(Money.mult!(get_change(@form.source, :amount), Decimal.new("1.13")))} + {Money.to_string!( + Bounties.calculate_contract_amount(get_change(@form.source, :amount)) + )}
    diff --git a/lib/algora_web/live/contract_live.ex b/lib/algora_web/live/contract_live.ex index 811ca54a0..81d7fa1e6 100644 --- a/lib/algora_web/live/contract_live.ex +++ b/lib/algora_web/live/contract_live.ex @@ -392,7 +392,9 @@ defmodule AlgoraWeb.ContractLive do <.icon name="tabler-circle-number-2 mr-2" class="size-8 text-success-400" /> When {@contractor.name} accepts, you will be charged - {Money.to_string!(Money.mult!(@bounty.amount, Decimal.new("1.13")))} + {Money.to_string!( + Bounties.final_contract_amount(@bounty.contract_type, @bounty.amount) + )} into escrow
  • @@ -404,7 +406,9 @@ defmodule AlgoraWeb.ContractLive do
    - {Money.to_string!(Money.mult!(@bounty.amount, Decimal.new("1.13")))} + {Money.to_string!( + Bounties.final_contract_amount(@bounty.contract_type, @bounty.amount) + )}
    diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 0fa1f4dfd..c93d8928d 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -1385,17 +1385,6 @@ defmodule AlgoraWeb.Org.DashboardLive do {@match.user.provider_meta["twitter_handle"]}
    - <%!--
    - - {@match[:hourly_rate] - |> Money.mult!(@match.user.hours_per_week || 30) - |> Money.mult!(Decimal.new("1.13")) - |> Money.to_string!()}/wk - -
    --%>
@@ -1477,13 +1466,10 @@ defmodule AlgoraWeb.Org.DashboardLive do
Offer contract
- {Money.to_string!( - Money.mult!( - @match[:hourly_rate] |> Money.mult!(@match.user.hours_per_week || 30), - Decimal.new("1.13") - ), - no_fraction_if_integer: false - )} / week + {@match[:hourly_rate] + |> Money.mult!(@match.user.hours_per_week || 30) + |> Bounties.calculate_contract_amount() + |> Money.to_string!(no_fraction_if_integer: false)} / week
diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index 0e16a40c3..e1bbbb9ae 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -132,7 +132,11 @@ defmodule AlgoraWeb.Org.Nav do bounty_res = Bounties.create_bounty( %{ - amount: amount, + amount: + case data.contract_type do + :marketplace -> Bounties.calculate_contract_amount(amount) + :bring_your_own -> amount + end, creator: socket.assigns.current_user, owner: socket.assigns.current_org, title: data.title, From c69dda6773172453605a147f33da1b278bc80d44 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 17:55:00 +0300 Subject: [PATCH 37/50] delete obsolete assertion --- test/algora_web/controllers/webhooks/stripe_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index 235254033..4730806ed 100644 --- a/test/algora_web/controllers/webhooks/stripe_controller_test.exs +++ b/test/algora_web/controllers/webhooks/stripe_controller_test.exs @@ -82,7 +82,7 @@ defmodule AlgoraWeb.Webhooks.StripeControllerTest do assert Repo.get(Bounty, bounty.id).status == :paid assert Repo.get(Tip, tip.id).status == :paid - assert Repo.get(Contract, contract.id).status == :paid + # assert Repo.get(Contract, contract.id).status == :paid assert_activity_names([:transaction_succeeded, :transaction_succeeded, :transaction_succeeded]) From f4703581c396d2e6f020be6f6c519ccf3edcbbe4 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 21:35:29 +0300 Subject: [PATCH 38/50] update copy --- lib/algora_web/live/jobs_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/live/jobs_live.ex b/lib/algora_web/live/jobs_live.ex index ae04144af..d486da0e8 100644 --- a/lib/algora_web/live/jobs_live.ex +++ b/lib/algora_web/live/jobs_live.ex @@ -51,7 +51,7 @@ defmodule AlgoraWeb.JobsLive do Jobs

- Open positions at top companies + Open positions at top open source companies

From 7c30f9a7a820d89f5d0f7c9d349ecec86919c3e8 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 21:35:52 +0300 Subject: [PATCH 39/50] add missing session check --- lib/algora_web/controllers/oauth_callback_controller.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index b680713d1..11e669b9e 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -38,7 +38,11 @@ defmodule AlgoraWeb.OAuthCallbackController do :redirect -> conn = AlgoraWeb.UserAuth.put_current_user(conn, user) - AlgoraWeb.Util.redirect_safe(conn, data[:return_to] || AlgoraWeb.UserAuth.signed_in_path(conn)) + + AlgoraWeb.Util.redirect_safe( + conn, + data[:return_to] || get_session(conn, :user_return_to) || AlgoraWeb.UserAuth.signed_in_path(conn) + ) end else {:error, reason} -> From d2341397aabdc2b0188d6180c9803e8a99b411ee Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 21:47:10 +0300 Subject: [PATCH 40/50] fix screenshot param --- lib/algora_web/controllers/og_image_controller.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/algora_web/controllers/og_image_controller.ex b/lib/algora_web/controllers/og_image_controller.ex index 12032b55e..47601abcb 100644 --- a/lib/algora_web/controllers/og_image_controller.ex +++ b/lib/algora_web/controllers/og_image_controller.ex @@ -143,11 +143,10 @@ defmodule AlgoraWeb.OGImageController do if params == "" do "?screenshot" else - params + params <> "&screenshot" end url = Path.join([AlgoraWeb.Endpoint.url() | path]) <> params - object_path = Path.join(["og"] ++ path ++ ["og.png"]) case ScreenshotQueue.generate_image(url, Keyword.put(@opts, :path, filepath)) do From fd2801be6856601bd536b51d9858f6bc523096af Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 21:57:00 +0300 Subject: [PATCH 41/50] misc --- lib/algora_web/components/header.ex | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/algora_web/components/header.ex b/lib/algora_web/components/header.ex index 042730f27..4ec29eed6 100644 --- a/lib/algora_web/components/header.ex +++ b/lib/algora_web/components/header.ex @@ -8,6 +8,7 @@ defmodule AlgoraWeb.Components.Header do defp nav_links do [ %{name: "Bounties", path: ~p"/bounties"}, + %{name: "Jobs", path: ~p"/jobs"}, %{name: "Testimonials", path: ~p"/testimonials"}, %{name: "Crowdfund", path: ~p"/crowdfund"}, %{name: "Docs", path: ~p"/docs"}, @@ -19,7 +20,7 @@ defmodule AlgoraWeb.Components.Header do ~H"""
@@ -84,7 +87,7 @@ defmodule Algora.Mailer do end defp html_section(:markdown, value) do - html = Cmark.to_html(value) + html = Algora.Markdown.render_unsafe(value) ~s"""
diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index 8c4d21c59..53f1c8e18 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -165,6 +165,8 @@ defmodule AlgoraWeb.UserAuth do user |> Ecto.Changeset.change(last_active_at: DateTime.utc_now()) |> Algora.Repo.update() + + Algora.Repo.insert_activity(user, %{type: :user_online, notify_users: []}) end) end diff --git a/lib/algora_web/live/jobs_live.ex b/lib/algora_web/live/jobs_live.ex index d486da0e8..f3d9871a4 100644 --- a/lib/algora_web/live/jobs_live.ex +++ b/lib/algora_web/live/jobs_live.ex @@ -258,20 +258,18 @@ defmodule AlgoraWeb.JobsLive do @impl true def handle_event("create_job", %{"job_posting" => params}, socket) do - case Jobs.create_job_posting(params) do - {:ok, job} -> - case Jobs.create_payment_session(job) do - {:ok, url} -> - {:noreply, redirect(socket, external: url)} - - {:error, reason} -> - Logger.error("Failed to create payment session: #{inspect(reason)}") - {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} - end - - {:error, changeset} -> - Logger.error("Failed to create job posting: #{inspect(changeset)}") + with {:ok, user} <- + Accounts.get_or_register_user(params["email"], %{type: :organization, display_name: params["company_name"]}), + {:ok, job} <- params |> Map.put("user_id", user.id) |> Jobs.create_job_posting(), + {:ok, url} <- Jobs.create_payment_session(job) do + {:noreply, redirect(socket, external: url)} + else + {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} + + {:error, reason} -> + Logger.error("Failed to create job posting: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong. Please try again.")} end end From 5d553a4dcddd8a887a0ea741bf90d43d26906c9e Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 24 Apr 2025 23:22:13 +0300 Subject: [PATCH 44/50] add bcc --- lib/algora_web/live/admin/campaign_live.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/algora_web/live/admin/campaign_live.ex b/lib/algora_web/live/admin/campaign_live.ex index a313adaef..04eec2800 100644 --- a/lib/algora_web/live/admin/campaign_live.ex +++ b/lib/algora_web/live/admin/campaign_live.ex @@ -396,6 +396,7 @@ defmodule AlgoraWeb.Admin.CampaignLive do {:ok, {preview, attachments}} -> Email.new() |> Email.to(opts[:recipient]["email"]) + |> Email.bcc(opts[:from]) |> Email.from(opts[:from]) |> Email.subject(opts[:subject]) |> Email.text_body(Mailer.text_template(markdown: preview)) From e7768924c45fbff20281ebfa3bfc4a5d5317c8f7 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 25 Apr 2025 00:02:13 +0300 Subject: [PATCH 45/50] use scheduled_at instead of schedule_in --- lib/algora/shared/util.ex | 37 ++++++++++++ lib/algora_web/live/admin/campaign_live.ex | 65 +++++++++++++++++++--- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex index 20f9ee1d1..276ed2659 100644 --- a/lib/algora/shared/util.ex +++ b/lib/algora/shared/util.ex @@ -203,4 +203,41 @@ defmodule Algora.Util do String.contains?(s1, s2) or String.contains?(s2, s1) end + + def next_occurrence_of_time(datetime) do + now = DateTime.utc_now() + + if DateTime.after?(datetime, now) do + datetime + else + %{hour: hour, minute: minute, second: second, microsecond: microsecond} = datetime + + now + |> DateTime.truncate(:second) + |> Map.put(:hour, hour) + |> Map.put(:minute, minute) + |> Map.put(:second, second) + |> Map.put(:microsecond, microsecond) + |> then(fn target_time -> + if DateTime.after?(target_time, now) do + target_time + else + DateTime.add(target_time, 24 * 60 * 60, :second) + end + end) + end + end + + def random_datetime(opts \\ []) do + now = DateTime.utc_now() + from = Keyword.get(opts, :from, DateTime.add(now, -365, :day)) + to = Keyword.get(opts, :to, now) + + from_unix = DateTime.to_unix(from) + to_unix = DateTime.to_unix(to) + + from_unix..to_unix + |> Enum.random() + |> DateTime.from_unix!() + end end diff --git a/lib/algora_web/live/admin/campaign_live.ex b/lib/algora_web/live/admin/campaign_live.ex index 04eec2800..f8936b4ce 100644 --- a/lib/algora_web/live/admin/campaign_live.ex +++ b/lib/algora_web/live/admin/campaign_live.ex @@ -6,11 +6,13 @@ defmodule AlgoraWeb.Admin.CampaignLive do import Ecto.Changeset import Ecto.Query + alias Algora.Accounts alias Algora.Activities.Jobs.SendCampaignEmail alias Algora.Admin alias Algora.Mailer alias Algora.Repo alias Algora.Settings + alias Algora.Util alias Algora.Workspace alias Algora.Workspace.Repository alias AlgoraWeb.LocalStore @@ -45,11 +47,15 @@ defmodule AlgoraWeb.Admin.CampaignLive do @impl true def mount(_params, _session, socket) do + timezone = if(params = get_connect_params(socket), do: params["timezone"]) + {:ok, socket + |> assign(:timezone, timezone) |> assign(:page_title, "Campaign") |> assign(:form, to_form(Campaign.changeset(%Campaign{}))) |> assign(:repo_cache, %{}) + |> assign(:user_cache, %{}) |> assign_preview()} end @@ -191,7 +197,7 @@ defmodule AlgoraWeb.Admin.CampaignLive do :for={key <- @csv_columns |> Enum.filter(&(&1 != "repo_url"))} label={key} > - <.cell value={row[key]} /> + <.cell value={row[key]} timezone={@timezone} /> <:action :let={row}> <.button type="button" phx-click="send_email" phx-value-email={row["email"]}> @@ -217,6 +223,17 @@ defmodule AlgoraWeb.Admin.CampaignLive do """ end + defp cell(%{value: %DateTime{}} = assigns) do + ~H""" + + {Calendar.strftime( + DateTime.from_naive!(@value, "Etc/UTC") |> DateTime.shift_zone!(@timezone), + "%Y/%m/%d, %H:%M:%S" + )} + + """ + end + defp cell(assigns) do ~H""" @@ -229,7 +246,7 @@ defmodule AlgoraWeb.Admin.CampaignLive do Enum.reduce(data, template, fn {key, value}, acc -> case value do value when is_list(value) -> acc - _ -> String.replace(acc, "%{#{key}}", value) + _ -> String.replace(acc, "%{#{key}}", to_string(value)) end end) end @@ -249,6 +266,39 @@ defmodule AlgoraWeb.Admin.CampaignLive do defp repo_key(_row), do: nil + defp assign_timestamps(socket) do + new_keys = + socket.assigns.csv_data + |> Enum.map(&Map.get(&1, "email")) + |> Enum.uniq() + + new_cache = + Map.new(new_keys, fn key -> + user = Accounts.get_user_by_email(key) + + if user do + {key, Util.next_occurrence_of_time(user.last_active_at || user.inserted_at)} + else + {key, Util.next_occurrence_of_time(Util.random_datetime())} + end + end) + + updated_cache = Map.merge(socket.assigns.user_cache, new_cache) + + csv_data = + Enum.map(socket.assigns.csv_data, fn row -> Map.put(row, "timestamp", Map.get(updated_cache, row["email"])) end) + + csv_columns = + csv_data + |> Enum.flat_map(&Map.keys/1) + |> Enum.uniq() + + socket + |> assign(:repo_cache, updated_cache) + |> assign(:csv_data, csv_data) + |> assign(:csv_columns, csv_columns) + end + defp assign_repo_names(socket) do new_keys = socket.assigns.csv_data @@ -342,6 +392,7 @@ defmodule AlgoraWeb.Admin.CampaignLive do socket |> assign(:csv_data, csv_data) |> assign_repo_names() + |> assign_timestamps() end defp assign_preview(socket) do @@ -374,16 +425,16 @@ defmodule AlgoraWeb.Admin.CampaignLive do id: Algora.Settings.get("email_campaign")["value"], subject: subject, recipient_email: recipient["email"], - recipient: Algora.Util.term_to_base64(recipient), + recipient: Util.term_to_base64(recipient), template: template, from_name: from_name, from_email: from_email, - preheader: render_preview(preheader, recipient) + preheader: render_preview(preheader, recipient), + scheduled_at: recipient["timestamp"] } end) - |> Enum.with_index() - |> Enum.reduce_while(:ok, fn {args, index}, acc -> - case args |> SendCampaignEmail.new(schedule_in: 5 * index) |> Oban.insert() do + |> Enum.reduce_while(:ok, fn args, acc -> + case args |> SendCampaignEmail.new(scheduled_at: args[:scheduled_at]) |> Oban.insert() do {:ok, _} -> {:cont, acc} {:error, _} -> {:halt, :error} end From bbfe7f6dda32f8dbe41eeda05e4347d4af0e95aa Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 25 Apr 2025 00:31:24 +0300 Subject: [PATCH 46/50] misc --- lib/algora_web/live/org/dashboard_live.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index c93d8928d..5e3749fb4 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -136,13 +136,13 @@ defmodule AlgoraWeb.Org.DashboardLive do @impl true def handle_params(params, _uri, socket) do - current_org = socket.assigns.current_org + %{current_org: current_org, previewed_user: previewed_user} = socket.assigns stats = Bounties.fetch_stats(org_id: current_org.id, current_user: socket.assigns[:current_user]) bounties = Bounties.list_bounties( - owner_id: current_org.id, + owner_id: previewed_user.id, limit: page_size(), status: :open, current_user: socket.assigns[:current_user] @@ -505,8 +505,6 @@ defmodule AlgoraWeb.Org.DashboardLive do {create_tip(assigns)} - - <.extras :if={is_nil(@current_context.handle)} /> <.sidebar From d1bd19d78385de558e3ae468ededb6239cb0e911 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 25 Apr 2025 13:22:25 +0300 Subject: [PATCH 47/50] conditionally display matches --- lib/algora/settings/settings.ex | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/algora/settings/settings.ex b/lib/algora/settings/settings.ex index 9f2d083f9..7f930ac57 100644 --- a/lib/algora/settings/settings.ex +++ b/lib/algora/settings/settings.ex @@ -70,16 +70,20 @@ defmodule Algora.Settings do end def get_org_matches(org) do - case get("org_matches:#{org.handle}") do - %{"matches" => matches} when is_list(matches) -> - load_matches(matches) - - _ -> - if tech_stack = List.first(org.tech_stack) do - get_tech_matches(tech_stack) - else - [] - end + if get_user_profile(org.handle) do + [] + else + case get("org_matches:#{org.handle}") do + %{"matches" => matches} when is_list(matches) -> + load_matches(matches) + + _ -> + if tech_stack = List.first(org.tech_stack) do + get_tech_matches(tech_stack) + else + [] + end + end end end From 2a1ac5bbecef08762a743c3a18a9ef673fafae80 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 25 Apr 2025 13:27:46 +0300 Subject: [PATCH 48/50] update visibility of GitHub star link and adjust responsive classes --- lib/algora_web/components/layouts/user.html.heex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/algora_web/components/layouts/user.html.heex b/lib/algora_web/components/layouts/user.html.heex index f265f795d..84b4be2b1 100644 --- a/lib/algora_web/components/layouts/user.html.heex +++ b/lib/algora_web/components/layouts/user.html.heex @@ -232,15 +232,15 @@
<.link :if={Algora.Stargazer.count()} - class="group w-fit outline-none hidden lg:flex" + class="group w-fit outline-none hidden sm:flex" target="_blank" rel="noopener" href={AlgoraWeb.Constants.get(:github_repo_url)} > -