diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 6ac25d21f..e024e2667 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -308,4 +308,9 @@ defmodule Algora.Accounts.User do def url(%{handle: handle, type: :individual}), do: "/@/#{handle}" def url(%{handle: handle, type: :organization}), do: "/org/#{handle}" def url(%{provider_login: handle}), do: "https://github.com/#{handle}" + + def last_context(%{last_context: nil}), do: default_context() + def last_context(%{last_context: last_context}), do: last_context + + def default_context, do: "personal" end diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index 7f577e78e..cf43d6943 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -6,6 +6,7 @@ defmodule AlgoraWeb.UserAuth do import Plug.Conn alias Algora.Accounts + alias Algora.Accounts.User alias Phoenix.LiveView def on_mount(:current_user, _params, session, socket) do @@ -185,12 +186,15 @@ defmodule AlgoraWeb.UserAuth do defp maybe_store_return_to(conn), do: conn + def signed_in_path_from_context("personal"), do: ~p"/home" + def signed_in_path_from_context(org_handle), do: ~p"/org/#{org_handle}" + + def signed_in_path(%User{} = user) do + signed_in_path_from_context(User.last_context(user)) + end + def signed_in_path(conn) do - case get_session(conn, :last_context) do - nil -> ~p"/home" - "personal" -> ~p"/home" - org_handle -> ~p"/org/#{org_handle}" - end + signed_in_path_from_context(get_session(conn, :last_context, User.default_context())) end defp login_code_ttl, do: 3600 diff --git a/lib/algora_web/live/org/nav.ex b/lib/algora_web/live/org/nav.ex index 515d7a171..d4edd285d 100644 --- a/lib/algora_web/live/org/nav.ex +++ b/lib/algora_web/live/org/nav.ex @@ -74,6 +74,12 @@ defmodule AlgoraWeb.Org.Nav do icon: "tabler-users", label: "Team" }, + %{ + href: "/org/#{org_handle}/transactions", + tab: :transactions, + icon: "tabler-credit-card", + label: "Transactions" + }, %{ href: "/org/#{org_handle}/analytics", tab: :analytics, diff --git a/lib/algora_web/live/org/transactions_live.ex b/lib/algora_web/live/org/transactions_live.ex new file mode 100644 index 000000000..d2d32621b --- /dev/null +++ b/lib/algora_web/live/org/transactions_live.ex @@ -0,0 +1,502 @@ +defmodule AlgoraWeb.Org.TransactionsLive do + @moduledoc false + use AlgoraWeb, :live_view + use LiveSvelte.Components + + import Ecto.Changeset + + alias Algora.Accounts.User + alias Algora.Payments + alias Algora.Stripe.ConnectCountries + alias Algora.Util + + defmodule PayoutAccountForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @countries ConnectCountries.list() + + embedded_schema do + field :country, :string + end + + def changeset(schema \\ %__MODULE__{}, attrs) do + schema + |> cast(attrs, [:country]) + |> validate_required([:country]) + |> validate_inclusion(:country, Enum.map(@countries, &elem(&1, 1))) + end + + def countries, do: @countries + end + + def mount(_params, _session, socket) do + if connected?(socket) do + Payments.subscribe() + end + + account = + case Payments.fetch_account(socket.assigns.current_org) do + {:ok, account} -> account + {:error, :not_found} -> nil + end + + {:ok, + socket + |> assign(:page_title, "Transactions") + |> assign(:show_create_payout_drawer, false) + |> assign(:show_manage_payout_drawer, false) + |> assign(:show_delete_confirmation, false) + |> assign(:payout_account_form, to_form(PayoutAccountForm.changeset(%PayoutAccountForm{}, %{}))) + |> assign(:account, account) + |> assign_transactions()} + end + + def handle_info(:payments_updated, socket) do + {:noreply, assign_transactions(socket)} + end + + def handle_event("show_create_payout_drawer", _params, socket) do + {:noreply, assign(socket, :show_create_payout_drawer, true)} + end + + def handle_event("show_manage_payout_drawer", _params, socket) do + {:noreply, assign(socket, :show_manage_payout_drawer, true)} + end + + def handle_event("close_drawer", _params, socket) do + {:noreply, socket |> assign(:show_create_payout_drawer, false) |> assign(:show_manage_payout_drawer, false)} + end + + def handle_event("view_dashboard", _params, socket) do + case Payments.create_login_link(socket.assigns.account) do + {:ok, %{url: url}} -> {:noreply, redirect(socket, external: url)} + {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} + end + end + + def handle_event("setup_payout_account", _params, socket) do + case Payments.create_account_link(socket.assigns.account, AlgoraWeb.Endpoint.url()) do + {:ok, %{url: url}} -> {:noreply, redirect(socket, external: url)} + {:error, _reason} -> {:noreply, put_flash(socket, :error, "Something went wrong")} + end + end + + def handle_event("create_payout_account", %{"payout_account_form" => params}, socket) do + changeset = + %PayoutAccountForm{} + |> PayoutAccountForm.changeset(params) + |> Map.put(:action, :validate) + + country = get_change(changeset, :country) + + if changeset.valid? do + with {:ok, account} <- + Payments.fetch_or_create_account(socket.assigns.current_org, country), + {:ok, %{url: url}} <- Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) do + {:noreply, redirect(socket, external: url)} + else + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Failed to create payout account")} + end + else + {:noreply, assign(socket, :payout_account_form, to_form(changeset))} + end + end + + def handle_event("show_delete_confirmation", _params, socket) do + {:noreply, assign(socket, :show_delete_confirmation, true)} + end + + def handle_event("cancel_delete", _params, socket) do + {:noreply, assign(socket, :show_delete_confirmation, false)} + end + + def handle_event("delete_payout_account", _params, socket) do + case Payments.delete_account(socket.assigns.account) do + {:ok, _account} -> + {:noreply, + socket + |> assign(:account, nil) + |> assign(:show_delete_confirmation, false) + |> assign(:show_manage_payout_drawer, false) + |> put_flash(:info, "Payout account deleted successfully")} + + {:error, _reason} -> + {:noreply, + socket + |> assign(:show_delete_confirmation, false) + |> put_flash(:error, "Failed to delete payout account")} + end + end + + 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 + ) + + balance = calculate_balance(transactions) + volume = calculate_volume(transactions) + + socket + |> assign(:transactions, transactions) + |> assign(:total_balance, balance) + |> assign(:total_volume, volume) + end + + defp calculate_balance(transactions) do + Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + case transaction.type do + type when type in [:charge, :deposit, :credit] -> + Money.add!(acc, transaction.net_amount) + + type when type in [:debit, :withdrawal, :transfer] -> + Money.sub!(acc, transaction.net_amount) + + _ -> + acc + end + end) + end + + defp calculate_volume(transactions) do + Enum.reduce(transactions, Money.new!(0, :USD), fn transaction, acc -> + case transaction.type do + type when type in [:charge, :credit] -> Money.add!(acc, transaction.net_amount) + _ -> acc + end + end) + end + + def render(assigns) do + ~H""" +
+
+
+
+

Transactions

+

View and manage your transaction history

+
+ <%= if @account do %> + <.button phx-click="show_manage_payout_drawer"> + Manage payout settings + + <% else %> + <.button phx-click="show_create_payout_drawer"> + Create payout account + + <% end %> +
+ <%= if @account do %> +
+ <%= if @account.charges_enabled do %> + <.badge variant="success" phx-click="show_manage_payout_drawer" class="cursor-pointer"> + Payout account active + + <% else %> + <.badge variant="warning" phx-click="show_manage_payout_drawer" class="cursor-pointer"> + Payout account setup required + + <% end %> +
+ <% end %> +
+ + +
+ <.card> + <.card_header> + <.card_title>Lifetime Volume + <.card_description>Total volume of your transactions + + <.card_content> + {Money.to_string!(@total_volume)} + + + <.card> + <.card_header> + <.card_title>Total Balance + <.card_description>Net balance across all transactions + + <.card_content> + {Money.to_string!(@total_balance)} + + +
+ + + <.card :if={length(@transactions) > 0}> + <.card_header> + <.card_title>Transaction History + + <.card_content> +
+
+ + + + + + + + + + + <%= for transaction <- @transactions do %> + + + + + + + <% end %> + +
Date + Description + +
+ Contact +
+
Amount
+ {Util.timestamp(transaction.inserted_at, @current_org.timezone)} + + {description(transaction)} + + <%= if linked_user = get_linked_user(transaction) do %> +
+ <.avatar class="h-8 w-8"> + <.avatar_image src={linked_user.avatar_url} alt={linked_user.name} /> + <.avatar_fallback>{String.first(linked_user.name)} + +
+
{linked_user.name}
+
+ @{User.handle(linked_user)} +
+
+
+ <% end %> +
+ <%= case transaction_direction(transaction.type) do %> + <% :plus -> %> + + {Money.to_string!(transaction.net_amount)} + + <% :minus -> %> + + -{Money.to_string!(transaction.net_amount)} + + <% end %> +
+
+
+ + +
+ <.drawer show={@show_create_payout_drawer} on_cancel="close_drawer" direction="right"> + <.drawer_header> + <.drawer_title>Payout Account + <.drawer_description>Create a payout account to receive your earnings + + <.drawer_content class="mt-4"> + <.simple_form for={@payout_account_form} phx-submit="create_payout_account"> +
+ <.input + field={@payout_account_form[:country]} + label="Country" + type="select" + prompt="" + options={ + PayoutAccountForm.countries() + |> Enum.map(fn {name, code} -> + {Algora.Misc.CountryEmojis.get(code, "🌎") <> " " <> name, code} + end) + } + helptext="Select the country where you or your business will legally operate." + /> +
+ <.button variant="outline" type="button" phx-click="close_drawer"> + Cancel + + <.button type="submit"> + Create Account + +
+
+ + + + <.drawer + :if={@account} + show={@show_manage_payout_drawer} + on_cancel="close_drawer" + direction="right" + > + <.drawer_header> + <.drawer_title>Payout Account + <.drawer_description>Manage your payout account + + <.drawer_content class="mt-4"> +
+
+ <.card> + <.card_header> + <.card_title>Account Status + + <.card_content> +
+
+
Account Type
+
+ {@account.type |> to_string() |> String.capitalize()} +
+
+
+
Country
+
+ {ConnectCountries.from_code(@account.country)} +
+
+
+
Can Accept Payments
+
+ <%= if @account.charges_enabled do %> +
✓
+ <% else %> +
✗
+ <% end %> +
+
+
+
Can Withdraw Payments
+
+ <%= if @account.payouts_enabled do %> +
✓
+ <% else %> +
✗
+ <% end %> +
+
+
+ + + + <.card :if={@account.details_submitted}> + <.card_header> + <.card_title>Payout Settings + + <.card_content> +
+
+
Payout Interval
+
+ {String.capitalize(@account.payout_interval)} +
+
+
+
Payout Speed
+
+ {@account.payout_speed} {ngettext("day", "days", @account.payout_speed)} +
+
+
+
Payout Currency
+
+ {@account.default_currency |> String.upcase()} +
+
+ <%= if bank_account = Enum.find(get_in(@account.provider_meta, ["external_accounts", "data"]), fn account -> account["default_for_currency"] end) do %> +
+
Bank Account
+
+
{bank_account["bank_name"]}
+
**** {bank_account["last4"]}
+
+
+ <% end %> +
+ + +
+ +
+ <.button class="flex-1" phx-click="show_delete_confirmation" variant="destructive"> + Delete account + + + <%= if not @account.details_submitted do %> + <.button class="flex-1" phx-click="setup_payout_account"> + Continue onboarding + + <% end %> + + <%= if @account.details_submitted and @account.type != :express do %> + <.button class="flex-1" phx-click="setup_payout_account"> + Update details + + <% end %> + + <%= if @account.details_submitted and @account.type == :express do %> + <.button class="flex-1" phx-click="setup_payout_account" variant="secondary"> + Update details + + <.button class="flex-1" phx-click="view_dashboard"> + View dashboard + + <% end %> +
+
+ + + <.dialog + :if={@show_delete_confirmation} + id="delete-confirmation-dialog" + show={@show_delete_confirmation} + on_cancel={JS.patch(~p"/user/transactions", [])} + > + <.dialog_content> + <.dialog_header> + <.dialog_title>Delete Payout Account + <.dialog_description> + Are you sure you want to delete your payout account? This action is irreversible and you will need to create a new account to receive payments. + + + <.dialog_footer> + <.button variant="outline" phx-click="cancel_delete">Cancel + <.button variant="destructive" phx-click="delete_payout_account">Delete Account + + + + """ + end + + defp transaction_direction(type) do + case type do + t when t in [:charge, :credit, :deposit] -> :plus + t when t in [:debit, :withdrawal, :transfer] -> :minus + end + end + + 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), + do: "Contract payment" + + defp description(%{type: type, bounty_id: bounty_id}) when type in [:debit, :credit] and not is_nil(bounty_id), + do: "Bounty payment" + + defp description(%{type: type}) when type in [:debit, :credit], do: "Payment" + + defp description(%{type: type}) do + type |> to_string() |> String.capitalize() + end + + defp get_linked_user(%{type: type, linked_transaction: %{user: user}}) when type in [:credit, :debit], do: user + + defp get_linked_user(_), do: nil +end diff --git a/lib/algora_web/live/payment/success_live.ex b/lib/algora_web/live/payment/success_live.ex index 92099e2ef..f2168059f 100644 --- a/lib/algora_web/live/payment/success_live.ex +++ b/lib/algora_web/live/payment/success_live.ex @@ -2,14 +2,24 @@ defmodule AlgoraWeb.Payment.SuccessLive do @moduledoc false use AlgoraWeb, :live_view + alias Algora.Accounts.User + def mount(_params, _session, socket) do socket = - if socket.assigns[:current_user] do - socket - |> put_flash(:info, "Your payment has been completed successfully!") - |> push_navigate(to: ~p"/user/transactions") - else - socket + case socket.assigns[:current_user] do + nil -> + socket + + current_user -> + to = + case User.last_context(current_user) do + "personal" -> ~p"/user/transactions" + org_handle -> ~p"/org/#{org_handle}/transactions" + end + + socket + |> put_flash(:info, "Your payment has been completed successfully!") + |> push_navigate(to: to) end {:ok, socket} diff --git a/lib/algora_web/live/swift_bounties_live.ex b/lib/algora_web/live/swift_bounties_live.ex index 99f88968e..afa4a238a 100644 --- a/lib/algora_web/live/swift_bounties_live.ex +++ b/lib/algora_web/live/swift_bounties_live.ex @@ -15,6 +15,7 @@ defmodule AlgoraWeb.SwiftBountiesLive do alias Algora.Validations alias Algora.Workspace alias AlgoraWeb.Components.Logos + alias AlgoraWeb.UserAuth require Logger @@ -71,19 +72,21 @@ defmodule AlgoraWeb.SwiftBountiesLive do end socket = - if socket.assigns[:current_user] do - push_navigate(socket, to: ~p"/home") - else - socket - |> assign(:page_title, "Fund Swift Together") - |> assign(:page_description, "Help grow the Swift ecosystem by funding the work we all depend on.") - |> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/swift.png") - |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) - |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) - |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) - |> assign(:pending_action, nil) - |> assign_tickets() - |> assign_active_repos() + case socket.assigns[:current_user] do + nil -> + socket + |> assign(:page_title, "Fund Swift Together") + |> assign(:page_description, "Help grow the Swift ecosystem by funding the work we all depend on.") + |> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/swift.png") + |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) + |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) + |> assign(:pending_action, nil) + |> assign_tickets() + |> assign_active_repos() + + current_user -> + push_navigate(socket, to: UserAuth.signed_in_path(current_user)) end {:ok, socket} diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 81819494b..fad709233 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -99,6 +99,7 @@ defmodule AlgoraWeb.Router do # live "/org/:org_handle/projects/:id", Project.ViewLive live "/org/:org_handle/jobs", Org.JobsLive, :index live "/org/:org_handle/jobs/:id", Org.JobLive, :index + live "/org/:org_handle/transactions", Org.TransactionsLive, :index live "/org/:org_handle/analytics", Org.AnalyticsLive, :index live "/org/:org_handle/chat", ChatLive, :index live "/org/:org_handle/settings", Org.SettingsLive, :edit