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""" +
View and manage your transaction history
+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)}
+
+
+ <% end %>
+
+
+ {linked_user.name}
+
+ @{User.handle(linked_user)}
+
+ |
+ + <%= case transaction_direction(transaction.type) do %> + <% :plus -> %> + + {Money.to_string!(transaction.net_amount)} + + <% :minus -> %> + + -{Money.to_string!(transaction.net_amount)} + + <% end %> + | +