diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index d18f71bd4..9ec9cc6a1 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -236,6 +236,7 @@ defmodule Algora.Accounts.User do :need_avatar, :website_url, :bio, + :country, :location, :timezone ]) diff --git a/lib/algora/integrations/stripe/connect_countries.ex b/lib/algora/integrations/stripe/connect_countries.ex new file mode 100644 index 000000000..58c4d9d81 --- /dev/null +++ b/lib/algora/integrations/stripe/connect_countries.ex @@ -0,0 +1,140 @@ +defmodule Algora.Stripe.ConnectCountries do + @moduledoc false + + @spec list() :: [{String.t(), String.t()}] + def list, + do: [ + {"Albania", "AL"}, + {"Algeria", "DZ"}, + {"Angola", "AO"}, + {"Antigua and Barbuda", "AG"}, + {"Argentina", "AR"}, + {"Armenia", "AM"}, + {"Australia", "AU"}, + {"Austria", "AT"}, + {"Azerbaijan", "AZ"}, + {"Bahamas", "BS"}, + {"Bahrain", "BH"}, + {"Bangladesh", "BD"}, + {"Belgium", "BE"}, + {"Benin", "BJ"}, + {"Bhutan", "BT"}, + {"Bolivia", "BO"}, + {"Bosnia and Herzegovina", "BA"}, + {"Botswana", "BW"}, + {"Brazil", "BR"}, + {"Brunei", "BN"}, + {"Bulgaria", "BG"}, + {"Cambodia", "KH"}, + {"Canada", "CA"}, + {"Chile", "CL"}, + {"Colombia", "CO"}, + {"Costa Rica", "CR"}, + {"Croatia", "HR"}, + {"Cyprus", "CY"}, + {"Czech Republic", "CZ"}, + {"Denmark", "DK"}, + {"Dominican Republic", "DO"}, + {"Ecuador", "EC"}, + {"Egypt", "EG"}, + {"El Salvador", "SV"}, + {"Estonia", "EE"}, + {"Ethiopia", "ET"}, + {"Finland", "FI"}, + {"France", "FR"}, + {"Gabon", "GA"}, + {"Gambia", "GM"}, + {"Germany", "DE"}, + {"Ghana", "GH"}, + {"Gibraltar", "GI"}, + {"Greece", "GR"}, + {"Guatemala", "GT"}, + {"Guyana", "GY"}, + {"Hong Kong", "HK"}, + {"Hungary", "HU"}, + {"Iceland", "IS"}, + {"India", "IN"}, + {"Indonesia", "ID"}, + {"Ireland", "IE"}, + {"Israel", "IL"}, + {"Italy", "IT"}, + {"Ivory Coast", "CI"}, + {"Jamaica", "JM"}, + {"Japan", "JP"}, + {"Jordan", "JO"}, + {"Kazakhstan", "KZ"}, + {"Kenya", "KE"}, + {"Kuwait", "KW"}, + {"Laos", "LA"}, + {"Latvia", "LV"}, + {"Liechtenstein", "LI"}, + {"Lithuania", "LT"}, + {"Luxembourg", "LU"}, + {"Macao", "MO"}, + {"Macedonia", "MK"}, + {"Madagascar", "MG"}, + {"Malaysia", "MY"}, + {"Malta", "MT"}, + {"Mauritius", "MU"}, + {"Mexico", "MX"}, + {"Moldova", "MD"}, + {"Monaco", "MC"}, + {"Mongolia", "MN"}, + {"Morocco", "MA"}, + {"Mozambique", "MZ"}, + {"Namibia", "NA"}, + {"Netherlands", "NL"}, + {"New Zealand", "NZ"}, + {"Nigeria", "NG"}, + {"Norway", "NO"}, + {"Oman", "OM"}, + {"Pakistan", "PK"}, + {"Panama", "PA"}, + {"Paraguay", "PY"}, + {"Peru", "PE"}, + {"Philippines", "PH"}, + {"Poland", "PL"}, + {"Portugal", "PT"}, + {"Qatar", "QA"}, + {"Romania", "RO"}, + {"Rwanda", "RW"}, + {"Saint Lucia", "LC"}, + {"San Marino", "SM"}, + {"Saudi Arabia", "SA"}, + {"Senegal", "SN"}, + {"Serbia", "RS"}, + {"Singapore", "SG"}, + {"Slovakia", "SK"}, + {"Slovenia", "SI"}, + {"South Africa", "ZA"}, + {"South Korea", "KR"}, + {"Spain", "ES"}, + {"Sri Lanka", "LK"}, + {"Sweden", "SE"}, + {"Switzerland", "CH"}, + {"Taiwan", "TW"}, + {"Tanzania", "TZ"}, + {"Thailand", "TH"}, + {"Trinidad and Tobago", "TT"}, + {"Tunisia", "TN"}, + {"Turkey", "TR"}, + {"United Arab Emirates", "AE"}, + {"United Kingdom", "GB"}, + {"United States", "US"}, + {"Uruguay", "UY"}, + {"Uzbekistan", "UZ"}, + {"Vietnam", "VN"} + ] + + @spec from_code(String.t()) :: String.t() + def from_code(code) do + list() |> Enum.find(&(elem(&1, 1) == code)) |> elem(0) || code + end + + @spec list_codes() :: [String.t()] + def list_codes, do: Enum.map(list(), &elem(&1, 1)) + + @spec account_type(String.t()) :: :standard | :express + def account_type("BR"), do: :standard + def account_type(_), do: :express +end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index a5c3405f0..053f8b805 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -2,10 +2,15 @@ defmodule Algora.Payments do @moduledoc false import Ecto.Query + alias Algora.Accounts + alias Algora.Accounts.User + alias Algora.Payments.Account alias Algora.Payments.Customer alias Algora.Payments.PaymentMethod alias Algora.Payments.Transaction alias Algora.Repo + alias Algora.Stripe.ConnectCountries + alias Algora.Util require Logger @@ -128,4 +133,119 @@ defmodule Algora.Payments do |> order_by([t], desc: t.inserted_at) |> Repo.all() end + + @spec fetch_or_create_account(user :: User.t(), country :: String.t()) :: + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} + def fetch_or_create_account(user, country) do + case fetch_account(user) do + {:ok, account} -> {:ok, account} + {:error, :not_found} -> create_account(user, country) + end + end + + @spec fetch_account(user :: User.t()) :: + {:ok, Account.t()} | {:error, :not_found} + def fetch_account(user) do + Repo.fetch_by(Account, user_id: user.id) + end + + @spec create_account(user :: User.t(), country :: String.t()) :: + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} + def create_account(user, country) do + type = ConnectCountries.account_type(country) + + with {:ok, stripe_account} <- create_stripe_account(%{country: country, type: type}) do + attrs = %{ + provider: "stripe", + provider_id: stripe_account.id, + provider_meta: Util.normalize_struct(stripe_account), + type: type, + user_id: user.id, + country: country + } + + %Account{} + |> Account.changeset(attrs) + |> Repo.insert() + end + end + + @spec create_stripe_account(attrs :: map()) :: + {:ok, Stripe.Account.t()} | {:error, Stripe.Error.t()} + defp create_stripe_account(%{country: country, type: type}) do + case Stripe.Account.create(%{country: country, type: to_string(type)}) do + {:ok, account} -> {:ok, account} + {:error, _reason} -> Stripe.Account.create(%{type: to_string(type)}) + end + end + + @spec create_account_link(account :: Account.t(), base_url :: String.t()) :: + {:ok, Stripe.AccountLink.t()} | {:error, Stripe.Error.t()} + def create_account_link(account, base_url) do + Stripe.AccountLink.create(%{ + account: account.provider_id, + refresh_url: "#{base_url}/callbacks/stripe/refresh", + return_url: "#{base_url}/callbacks/stripe/return", + type: "account_onboarding" + }) + end + + @spec create_login_link(account :: Account.t()) :: + {:ok, Stripe.LoginLink.t()} | {:error, Stripe.Error.t()} + def create_login_link(account) do + Stripe.LoginLink.create(account.provider_id, %{}) + end + + @spec update_account(account :: Account.t(), stripe_account :: Stripe.Account.t()) :: + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} + def update_account(account, stripe_account) do + account + |> Account.changeset(%{ + provider: "stripe", + provider_id: stripe_account.id, + provider_meta: Util.normalize_struct(stripe_account), + charges_enabled: stripe_account.charges_enabled, + payouts_enabled: stripe_account.payouts_enabled, + payout_interval: stripe_account.settings.payouts.schedule.interval, + payout_speed: stripe_account.settings.payouts.schedule.delay_days, + default_currency: stripe_account.default_currency, + details_submitted: stripe_account.details_submitted, + country: stripe_account.country, + service_agreement: get_service_agreement(stripe_account) + }) + |> Repo.update() + end + + @spec refresh_stripe_account(user :: User.t()) :: + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found} | {:error, Stripe.Error.t()} + def refresh_stripe_account(user) do + with {:ok, account} <- fetch_account(user), + {:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id, []), + {:ok, updated_account} <- update_account(account, stripe_account) do + user = Accounts.get_user(account.user_id) + + if user && stripe_account.charges_enabled do + Accounts.update_settings(user, %{country: stripe_account.country}) + end + + {:ok, updated_account} + end + end + + @spec get_service_agreement(account :: Stripe.Account.t()) :: String.t() + defp get_service_agreement(%{tos_acceptance: %{service_agreement: agreement}} = _account) when not is_nil(agreement) do + agreement + end + + @spec get_service_agreement(account :: Stripe.Account.t()) :: String.t() + defp get_service_agreement(%{capabilities: capabilities}) do + if is_nil(capabilities[:card_payments]), do: "recipient", else: "full" + end + + @spec delete_account(account :: Account.t()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()} + def delete_account(account) do + with {:ok, _stripe_account} <- Stripe.Account.delete(account.provider_id) do + Repo.delete(account) + end + end end diff --git a/lib/algora/payments/schemas/account.ex b/lib/algora/payments/schemas/account.ex index 9acbd80d6..e38c1d041 100644 --- a/lib/algora/payments/schemas/account.ex +++ b/lib/algora/payments/schemas/account.ex @@ -2,22 +2,27 @@ defmodule Algora.Payments.Account do @moduledoc false use Algora.Schema + alias Algora.Stripe + @derive {Inspect, except: [:provider_meta]} typed_schema "accounts" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map + field :provider, :string, null: false + field :provider_id, :string, null: false + field :provider_meta, :map, null: false field :name, :string - field :details_submitted, :boolean, default: false - field :charges_enabled, :boolean, default: false + field :details_submitted, :boolean, default: false, null: false + field :charges_enabled, :boolean, default: false, null: false + field :payouts_enabled, :boolean, default: false, null: false + field :payout_interval, :string + field :payout_speed, :integer + field :default_currency, :string field :service_agreement, :string - field :country, :string - field :type, Ecto.Enum, values: [:standard, :express] - field :region, Ecto.Enum, values: [:US, :EU] - field :stale, :boolean, default: false + field :country, :string, null: false + field :type, Ecto.Enum, values: [:standard, :express], null: false + field :stale, :boolean, default: false, null: false - belongs_to :user, Algora.Accounts.User + belongs_to :user, Algora.Accounts.User, null: false timestamps() end @@ -30,12 +35,31 @@ defmodule Algora.Payments.Account do :provider_meta, :details_submitted, :charges_enabled, + :payouts_enabled, + :payout_interval, + :payout_speed, + :default_currency, :service_agreement, :country, :type, - :region, - :stale + :stale, + :user_id + ]) + |> validate_required([ + :provider, + :provider_id, + :provider_meta, + :details_submitted, + :charges_enabled, + :payouts_enabled, + :country, + :type, + :stale, + :user_id ]) - |> validate_required([:provider, :provider_id, :provider_meta]) + |> validate_inclusion(:type, [:standard, :express]) + |> validate_inclusion(:country, Stripe.ConnectCountries.list_codes()) + |> foreign_key_constraint(:user_id) + |> generate_id() end end diff --git a/lib/algora/payments/schemas/customer.ex b/lib/algora/payments/schemas/customer.ex index e7d3b9612..c84bf8d2b 100644 --- a/lib/algora/payments/schemas/customer.ex +++ b/lib/algora/payments/schemas/customer.ex @@ -9,7 +9,6 @@ defmodule Algora.Payments.Customer do field :provider_meta, :map field :name, :string - field :region, Ecto.Enum, values: [:US, :EU] belongs_to :user, Algora.Accounts.User @@ -22,8 +21,8 @@ defmodule Algora.Payments.Customer do def changeset(customer, attrs) do customer - |> cast(attrs, [:user_id, :provider, :provider_id, :provider_meta, :name, :region]) - |> validate_required([:user_id, :provider, :provider_id, :provider_meta, :name, :region]) + |> cast(attrs, [:user_id, :provider, :provider_id, :provider_meta, :name]) + |> validate_required([:user_id, :provider, :provider_id, :provider_meta, :name]) |> unique_constraint(:user_id) end end diff --git a/lib/algora/repo.ex b/lib/algora/repo.ex index 3f337651b..5a788546f 100644 --- a/lib/algora/repo.ex +++ b/lib/algora/repo.ex @@ -37,7 +37,7 @@ defmodule Algora.Repo do fetch_one(query, opts) end - @spec fetch_by(Ecto.Queryable.t(), Keyword.t(), Keyword.t()) :: + @spec fetch_by(Ecto.Queryable.t(), Keyword.t() | map(), Keyword.t()) :: {:ok, struct()} | {:error, :not_found} def fetch_by(queryable, clauses, opts \\ []) do query = diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex index d386f4ffd..647b1471a 100644 --- a/lib/algora_web/components/ui/drawer.ex +++ b/lib/algora_web/components/ui/drawer.ex @@ -45,7 +45,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do "fixed z-50 transform border bg-background transition-transform duration-300 ease-in-out", case @direction do "bottom" -> "inset-x-0 bottom-0 rounded-t-xl" - "right" -> "inset-y-0 right-0 h-full max-w-lg" + "right" -> "inset-y-0 right-0 h-full max-w-lg w-full" end, case @direction do "bottom" -> if(@show, do: "translate-y-0", else: "translate-y-full") diff --git a/lib/algora_web/controllers/stripe_callback_controller.ex b/lib/algora_web/controllers/stripe_callback_controller.ex new file mode 100644 index 000000000..f3a424965 --- /dev/null +++ b/lib/algora_web/controllers/stripe_callback_controller.ex @@ -0,0 +1,28 @@ +defmodule AlgoraWeb.StripeCallbackController do + use AlgoraWeb, :controller + + alias Algora.Payments + + def refresh(conn, params), do: refresh_stripe_account(conn, params) + def return(conn, params), do: refresh_stripe_account(conn, params) + + defp refresh_stripe_account(conn, _params) do + case conn.assigns[:current_user] do + nil -> + conn + |> redirect(to: ~p"/auth/login") + |> halt() + + current_user -> + case Payments.refresh_stripe_account(current_user) do + {:ok, _account} -> + redirect(conn, to: ~p"/user/transactions") + + {:error, _reason} -> + conn + |> put_flash(:error, "Failed to refresh Stripe account") + |> redirect(to: ~p"/user/transactions") + end + end + end +end diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 793b9421d..8cb3fb5db 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -3,18 +3,50 @@ defmodule AlgoraWeb.User.TransactionsLive do 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 + {:ok, account} = Payments.fetch_account(socket.assigns.current_user) + {:ok, socket - |> assign(:page_title, "Your transactions") + |> 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 @@ -22,6 +54,80 @@ defmodule AlgoraWeb.User.TransactionsLive 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_user, 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( @@ -66,9 +172,35 @@ defmodule AlgoraWeb.User.TransactionsLive do def render(assigns) do ~H"""
View and manage your transaction history
+View and manage your transaction history
+