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

<.link href={Constants.get(:github_repo_url)} rel="noopener" target="_blank" - class="font-medium" + class="font-semibold" > 🎉 Algora is now open source! -

+
<.wordmark class="h-8 w-auto text-foreground" />
@@ -39,18 +39,19 @@ defmodule AlgoraWeb.Components.Header do
-
<%= for transaction <- @transactions do %> - + <% end %> @@ -475,9 +613,90 @@ defmodule AlgoraWeb.ContractLive do + <.drawer :if={@current_user} show={@show_authorize_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Authorize payment + <.drawer_description> + 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="authorize_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]} + 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_title>Pay contract <.drawer_description> You can pay any amount at any time. @@ -569,12 +788,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, @@ -590,7 +809,19 @@ 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 + + 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, + success_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}"), + cancel_url: url(~p"/#{bounty.owner.handle}/contracts/#{bounty.id}") ) end @@ -603,7 +834,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 @@ -622,11 +855,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, :requires_release], bounty_id: socket.assigns.bounty.id - ) + ] + |> Payments.list_transactions() + |> Enum.filter(&(&1.type == :charge or &1.status in [:succeeded, :requires_release])) balance = calculate_balance(transactions) volume = calculate_volume(transactions) @@ -638,7 +873,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) @@ -653,7 +890,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 @@ -661,16 +900,22 @@ 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 - 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, status: :requires_release}), do: "Ready to release" - defp description(%{type: :debit}), do: "Released" + defp description(%{type: :debit, status: :succeeded}), do: "Released" defp description(%{type: _type}), do: nil end diff --git a/lib/algora_web/live/home_live.ex b/lib/algora_web/live/home_live.ex index 3ed9de64e..b0825029b 100644 --- a/lib/algora_web/live/home_live.ex +++ b/lib/algora_web/live/home_live.ex @@ -186,7 +186,7 @@ defmodule AlgoraWeb.HomeLive do
<.pattern /> -
+

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

@@ -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 diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index 0cef5e3cf..1db5c3e13 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)) @@ -130,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] @@ -194,7 +200,7 @@ defmodule AlgoraWeb.Org.DashboardLive do def render(assigns) do ~H"""
-
+
<.section :if={@payable_bounties != %{}}> <.card> <.card_header> @@ -320,13 +326,19 @@ defmodule AlgoraWeb.Org.DashboardLive do title={"#{header_prefix(@previewed_user)} Contributors"} subtitle="Share bounties, tips or contract opportunities with your top contributors" > -
+
{Util.timestamp(transaction.inserted_at, @timezone)} @@ -342,19 +473,26 @@ defmodule AlgoraWeb.ContractLive do
- {description(transaction)} +
+ {description(transaction)} + <.button + :if={ + transaction.type == :debit and + transaction.status == :requires_release + } + size="sm" + phx-click="release_funds" + phx-disable-with="Releasing..." + 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)} +
<%= for %Contributor{user: user} <- @contributors 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 %> @@ -344,9 +356,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} @@ -364,6 +377,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 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/withhold escrow end of week +
  • +
+
<%= for match <- @matches do %> <.match_card match={match} @@ -386,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 %> @@ -630,24 +666,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", "contract_type" => contract_type}, + 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{ + contract_type: String.to_existing_atom(contract_type), + 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" }) @@ -1172,7 +1215,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
<.link :if={@user.provider_login} @@ -1219,7 +1262,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 @@ -1228,7 +1271,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 @@ -1254,8 +1297,9 @@ defmodule AlgoraWeb.Org.DashboardLive do phx-click="share_opportunity" phx-value-user_id={@user.id} phx-value-type="contract" + phx-value-contract_type={@contract_type} 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 @@ -1286,84 +1330,65 @@ 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" - > - - {@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 + 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} + +
+
- <.icon name="tabler-brand-x" class="shrink-0 h-4 w-4" /> - {@match.user.provider_meta["twitter_handle"]} - -
-
- - {Money.to_string!(@match[:hourly_rate])}/hr - + <.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"]} + +
-
- -
- <.button - phx-click="share_opportunity" - phx-value-user_id={@match.user.id} - phx-value-type="contract" - > - <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract - -
- -
-
+
Completed - + {@match.user.transactions_count} {ngettext( "bounty", @@ -1372,7 +1397,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )} across - + {ngettext( "%{count} project", "%{count} projects", @@ -1380,7 +1405,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )}
-
+
<%= for {project, total_earned} <- @match.projects |> Enum.take(2) do %> <.link navigate={User.url(project)} @@ -1393,7 +1418,7 @@ defmodule AlgoraWeb.Org.DashboardLive do
-
+
{project.name}
@@ -1404,7 +1429,7 @@ defmodule AlgoraWeb.Org.DashboardLive do )}
- + {total_earned} awarded @@ -1415,6 +1440,41 @@ 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-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" + > +
+ Offer contract +
+ {@match[:hourly_rate] + |> Money.mult!(@match.user.hours_per_week || 30) + |> Bounties.calculate_contract_amount() + |> Money.to_string!(no_fraction_if_integer: false)} / week +
+
+ +
+
+
+
""" end @@ -1522,7 +1582,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} @@ -1953,7 +2018,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 %>
diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index 74f008cf3..e1bbbb9ae 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -126,21 +126,27 @@ 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 bounty_res = Bounties.create_bounty( %{ + 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, - 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 + visibility: :exclusive, + contract_type: data.contract_type ) case bounty_res do diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex index db5009a7c..2c43ff5f1 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) @@ -26,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)}") @@ -39,12 +45,16 @@ 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)) |> 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}") || @@ -89,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 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), 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 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 diff --git a/test/algora_web/controllers/webhooks/stripe_controller_test.exs b/test/algora_web/controllers/webhooks/stripe_controller_test.exs index b7760b721..4730806ed 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) } } @@ -81,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]) @@ -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) } }