From 550e76b963ccf1bc18d28283fc5ec6285546df5b Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 4 Jan 2025 16:20:21 +0300 Subject: [PATCH 01/12] add create payout account form --- .../integrations/stripe/connect_countries.ex | 128 ++++++++++++++++++ lib/algora_web/live/user/transactions_live.ex | 99 +++++++++++++- 2 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 lib/algora/integrations/stripe/connect_countries.ex diff --git a/lib/algora/integrations/stripe/connect_countries.ex b/lib/algora/integrations/stripe/connect_countries.ex new file mode 100644 index 000000000..13da54831 --- /dev/null +++ b/lib/algora/integrations/stripe/connect_countries.ex @@ -0,0 +1,128 @@ +defmodule Algora.Stripe.ConnectCountries do + @moduledoc false + + 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"}, + {"China", "CN"}, + {"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"} + ] +end diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 793b9421d..167878028 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -5,8 +5,31 @@ defmodule AlgoraWeb.User.TransactionsLive do 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() @@ -15,6 +38,8 @@ defmodule AlgoraWeb.User.TransactionsLive do {:ok, socket |> assign(:page_title, "Your transactions") + |> assign(:show_payout_drawer, true) + |> assign(:payout_account_form, to_form(PayoutAccountForm.changeset(%PayoutAccountForm{}, %{}))) |> assign_transactions()} end @@ -22,6 +47,35 @@ defmodule AlgoraWeb.User.TransactionsLive do {:noreply, assign_transactions(socket)} end + def handle_event("show_payout_drawer", _params, socket) do + {:noreply, assign(socket, :show_payout_drawer, true)} + end + + def handle_event("close_drawer", _params, socket) do + {:noreply, assign(socket, :show_payout_drawer, false)} + end + + def handle_event("create_payout_account", %{"payout_account_form" => params}, socket) do + changeset = + %PayoutAccountForm{} + |> PayoutAccountForm.changeset(params) + |> Map.put(:action, :validate) + + case changeset do + %{valid?: true} = changeset -> + # TODO: Actually create the payout account + IO.inspect(changeset.changes, label: "Would create payout account with") + + {:noreply, + socket + |> put_flash(:info, "Payout account created!") + |> assign(:show_payout_drawer, false)} + + %{valid?: false} = changeset -> + {:noreply, assign(socket, :payout_account_form, to_form(changeset))} + end + end + defp assign_transactions(socket) do transactions = Payments.list_transactions( @@ -66,9 +120,15 @@ defmodule AlgoraWeb.User.TransactionsLive do def render(assigns) do ~H"""
-
-

Your Transactions

-

View and manage your transaction history

+
+
+

Your Transactions

+

View and manage your transaction history

+
+ <.button phx-click="show_payout_drawer"> + <.icon name="tabler-plus" class="w-4 h-4 mr-2 -ml-1" /> + Create payout account +
@@ -162,6 +222,39 @@ defmodule AlgoraWeb.User.TransactionsLive do
+ <.drawer show={@show_payout_drawer} on_cancel="close_drawer" direction="right"> + <.drawer_header> + <.drawer_title>Create 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 + +
+
+ + + """ end From 1cc869d642679b0a5f324e2be7146922379c2ab6 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 4 Jan 2025 19:19:54 +0300 Subject: [PATCH 02/12] add stripe connect flow --- lib/algora/accounts/schemas/user.ex | 1 + .../integrations/stripe/connect_countries.ex | 2 + lib/algora/payments/payments.ex | 95 +++++++++++++++++++ lib/algora/payments/schemas/account.ex | 43 ++++++--- .../controllers/stripe_callback_controller.ex | 28 ++++++ lib/algora_web/live/user/transactions_live.ex | 47 +++++++-- lib/algora_web/router.ex | 2 + .../20250104150728_update_accounts.exs | 21 ++++ 8 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 lib/algora_web/controllers/stripe_callback_controller.ex create mode 100644 priv/repo/migrations/20250104150728_update_accounts.exs 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 index 13da54831..e5d642206 100644 --- a/lib/algora/integrations/stripe/connect_countries.ex +++ b/lib/algora/integrations/stripe/connect_countries.ex @@ -125,4 +125,6 @@ defmodule Algora.Stripe.ConnectCountries do {"Uzbekistan", "UZ"}, {"Vietnam", "VN"} ] + + def list_codes, do: Enum.map(list(), &elem(&1, 1)) end diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index a5c3405f0..d7c29b886 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -2,10 +2,14 @@ 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.Util require Logger @@ -128,4 +132,95 @@ defmodule Algora.Payments do |> order_by([t], desc: t.inserted_at) |> Repo.all() end + + def get_account(user_id, region) do + Account + |> where([a], a.user_id == ^user_id and a.region == ^region) + |> Repo.one() + end + + @spec create_account(user :: User.t(), attrs :: %{optional(atom()) => any()}) :: + {:ok, Account.t()} | {:error, any()} + def create_account(user, attrs) do + with {:ok, stripe_account} <- create_stripe_account(attrs) do + attrs = %{ + provider: "stripe", + provider_id: stripe_account.id, + provider_meta: Util.normalize_struct(stripe_account), + type: attrs.type, + region: :US, + user_id: user.id, + country: attrs.country + } + + %Account{} + |> Account.changeset(attrs) + |> Repo.insert() + end + end + + @spec create_stripe_account(attrs :: %{optional(atom()) => any()}) :: + {:ok, Stripe.Account.t()} | {:error, Stripe.Error.t()} + defp create_stripe_account(%{country: country, type: type}) do + case Stripe.Account.create(%{country: country, type: type}) do + {:ok, account} -> {:ok, account} + {:error, _reason} -> Stripe.Account.create(%{type: 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.Account.t()} | {:error, Stripe.Error.t()} + def create_login_link(account) do + Stripe.Account.create_login_link(account.provider_id, %{}) + end + + @spec refresh_stripe_account(user_id :: binary()) :: + {:ok, Account.t()} | {:error, :account_not_found} | {:error, any()} + def refresh_stripe_account(user_id) do + case get_account(user_id, :US) do + nil -> + {:error, :account_not_found} + + account -> + with {:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id) do + attrs = %{ + charges_enabled: stripe_account.charges_enabled, + details_submitted: stripe_account.details_submitted, + country: stripe_account.country, + service_agreement: stripe_account.tos_acceptance.service_agreement, + provider_meta: Util.normalize_struct(stripe_account) + } + + account + |> Account.changeset(attrs) + |> Repo.update() + |> case do + {:ok, updated_account} -> + if stripe_account.charges_enabled do + account.user_id + |> Accounts.get_user!() + |> Accounts.update_settings(%{country: stripe_account.country}) + end + + # TODO: enqueue pending transfers + + {:ok, updated_account} + + error -> + error + end + end + end + end end diff --git a/lib/algora/payments/schemas/account.ex b/lib/algora/payments/schemas/account.ex index 9acbd80d6..0ae8ef9f0 100644 --- a/lib/algora/payments/schemas/account.ex +++ b/lib/algora/payments/schemas/account.ex @@ -2,22 +2,24 @@ 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 :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 :region, Ecto.Enum, values: [:US, :EU], 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 @@ -34,8 +36,25 @@ defmodule Algora.Payments.Account do :country, :type, :region, - :stale + :stale, + :user_id + ]) + |> validate_required([ + :provider, + :provider_id, + :provider_meta, + :details_submitted, + :charges_enabled, + :country, + :type, + :region, + :stale, + :user_id ]) - |> validate_required([:provider, :provider_id, :provider_meta]) + |> validate_inclusion(:type, [:standard, :express]) + |> validate_inclusion(:region, [:US, :EU]) + |> validate_inclusion(:country, Stripe.ConnectCountries.list_codes()) + |> foreign_key_constraint(:user_id) + |> generate_id() end end 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..a07a3a1a9 --- /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.id) 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 167878028..d84520660 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -63,13 +63,44 @@ defmodule AlgoraWeb.User.TransactionsLive do case changeset do %{valid?: true} = changeset -> - # TODO: Actually create the payout account - IO.inspect(changeset.changes, label: "Would create payout account with") - - {:noreply, - socket - |> put_flash(:info, "Payout account created!") - |> assign(:show_payout_drawer, false)} + # Get or create Stripe account + account = Payments.get_account(socket.assigns.current_user.id, :US) + + result = + if is_nil(account) do + Payments.create_account(socket.assigns.current_user, %{ + country: changeset.changes.country, + type: "express" + }) + else + {:ok, account} + end + + case result do + {:ok, account} -> + if account.charges_enabled do + if account.type == :express do + {:ok, %{url: url}} = Payments.create_login_link(account) + + {:noreply, redirect(socket, external: url)} + else + {:noreply, + socket + |> put_flash(:info, "Account already set up!") + |> assign(:show_payout_drawer, false)} + end + else + {:ok, %{url: url}} = Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) + + {:noreply, redirect(socket, external: url)} + end + + {:error, _reason} -> + {:noreply, + socket + |> put_flash(:error, "Failed to create payout account") + |> assign(:show_payout_drawer, false)} + end %{valid?: false} = changeset -> {:noreply, assign(socket, :payout_account_form, to_form(changeset))} @@ -224,7 +255,7 @@ defmodule AlgoraWeb.User.TransactionsLive do
<.drawer show={@show_payout_drawer} on_cancel="close_drawer" direction="right"> <.drawer_header> - <.drawer_title>Create Payout Account + <.drawer_title>Payout Account <.drawer_description>Create a payout account to receive your earnings <.drawer_content class="mt-4"> diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index ba0a3fd2b..6627967e2 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -44,6 +44,8 @@ defmodule AlgoraWeb.Router do get "/set_context/:context", ContextController, :set + get "/callbacks/stripe/refresh", StripeCallbackController, :refresh + get "/callbacks/stripe/return", StripeCallbackController, :return get "/callbacks/:provider/oauth", OAuthCallbackController, :new get "/callbacks/:provider/installation", InstallationCallbackController, :new get "/auth/logout", OAuthCallbackController, :sign_out diff --git a/priv/repo/migrations/20250104150728_update_accounts.exs b/priv/repo/migrations/20250104150728_update_accounts.exs new file mode 100644 index 000000000..09a83b2e5 --- /dev/null +++ b/priv/repo/migrations/20250104150728_update_accounts.exs @@ -0,0 +1,21 @@ +defmodule Algora.Repo.Migrations.UpdateAccounts do + use Ecto.Migration + + def up do + alter table(:accounts) do + modify :service_agreement, :string, null: true + end + + drop index(:accounts, [:user_id]) + create unique_index(:accounts, [:user_id, :region]) + end + + def down do + alter table(:accounts) do + modify :service_agreement, :string, null: false + end + + create index(:accounts, [:user_id]) + drop unique_index(:accounts, [:user_id, :region]) + end +end From 2f3b968b98dac7db99edb46b2f194aac74d13214 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 4 Jan 2025 19:48:36 +0300 Subject: [PATCH 03/12] handle edge case while determining service agreement --- lib/algora/payments/payments.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index d7c29b886..1e45919da 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -198,7 +198,7 @@ defmodule Algora.Payments do charges_enabled: stripe_account.charges_enabled, details_submitted: stripe_account.details_submitted, country: stripe_account.country, - service_agreement: stripe_account.tos_acceptance.service_agreement, + service_agreement: get_service_agreement(stripe_account), provider_meta: Util.normalize_struct(stripe_account) } @@ -223,4 +223,12 @@ defmodule Algora.Payments do end end end + + defp get_service_agreement(%{tos_acceptance: %{service_agreement: agreement}} = _account) when not is_nil(agreement) do + agreement + end + + defp get_service_agreement(%{capabilities: capabilities}) do + if is_nil(capabilities[:card_payments]), do: "recipient", else: "full" + end end From 6e8fae367011b6daea96f5c1a02dea0b3829c020 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 4 Jan 2025 19:49:18 +0300 Subject: [PATCH 04/12] set payout account type based on country --- lib/algora/integrations/stripe/connect_countries.ex | 7 ++++++- lib/algora/payments/payments.ex | 3 +++ lib/algora_web/live/user/transactions_live.ex | 3 +-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/algora/integrations/stripe/connect_countries.ex b/lib/algora/integrations/stripe/connect_countries.ex index e5d642206..d523bae50 100644 --- a/lib/algora/integrations/stripe/connect_countries.ex +++ b/lib/algora/integrations/stripe/connect_countries.ex @@ -1,6 +1,7 @@ defmodule Algora.Stripe.ConnectCountries do @moduledoc false + @spec list() :: [{String.t(), String.t()}] def list, do: [ {"Albania", "AL"}, @@ -27,7 +28,6 @@ defmodule Algora.Stripe.ConnectCountries do {"Cambodia", "KH"}, {"Canada", "CA"}, {"Chile", "CL"}, - {"China", "CN"}, {"Colombia", "CO"}, {"Costa Rica", "CR"}, {"Croatia", "HR"}, @@ -126,5 +126,10 @@ defmodule Algora.Stripe.ConnectCountries do {"Vietnam", "VN"} ] + @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 1e45919da..113423315 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -9,6 +9,7 @@ defmodule Algora.Payments do alias Algora.Payments.PaymentMethod alias Algora.Payments.Transaction alias Algora.Repo + alias Algora.Stripe.ConnectCountries alias Algora.Util require Logger @@ -142,6 +143,8 @@ defmodule Algora.Payments do @spec create_account(user :: User.t(), attrs :: %{optional(atom()) => any()}) :: {:ok, Account.t()} | {:error, any()} def create_account(user, attrs) do + attrs = Map.put(attrs, :type, ConnectCountries.account_type(attrs.country)) + with {:ok, stripe_account} <- create_stripe_account(attrs) do attrs = %{ provider: "stripe", diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index d84520660..e9329df3d 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -69,8 +69,7 @@ defmodule AlgoraWeb.User.TransactionsLive do result = if is_nil(account) do Payments.create_account(socket.assigns.current_user, %{ - country: changeset.changes.country, - type: "express" + country: changeset.changes.country }) else {:ok, account} From f7a55ee6c8e70ba88e8888513c26e91859177f95 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 4 Jan 2025 23:59:13 +0300 Subject: [PATCH 05/12] add payout account status indicator --- lib/algora_web/live/user/transactions_live.ex | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index e9329df3d..11c141a40 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -35,11 +35,14 @@ defmodule AlgoraWeb.User.TransactionsLive do Payments.subscribe() end + account = Payments.get_account(socket.assigns.current_user.id, :US) + {:ok, socket |> assign(:page_title, "Your transactions") - |> assign(:show_payout_drawer, true) + |> assign(:show_payout_drawer, false) |> assign(:payout_account_form, to_form(PayoutAccountForm.changeset(%PayoutAccountForm{}, %{}))) + |> assign(:account, account) |> assign_transactions()} end @@ -150,15 +153,26 @@ defmodule AlgoraWeb.User.TransactionsLive do def render(assigns) do ~H"""
-
-
-

Your Transactions

-

View and manage your transaction history

+
+
+
+

Your Transactions

+

View and manage your transaction history

+
+ <.button phx-click="show_payout_drawer"> + <.icon name="tabler-plus" class="w-4 h-4 mr-2 -ml-1" /> + Create payout account +
- <.button phx-click="show_payout_drawer"> - <.icon name="tabler-plus" class="w-4 h-4 mr-2 -ml-1" /> - Create payout account - + <%= if @account do %> +
+ <.badge variant={if @account.charges_enabled, do: "success", else: "warning"}> + {if @account.charges_enabled, + do: "Payout account active", + else: "Payout account setup required"} + +
+ <% end %>
From c6fd76cede97e7ff96bf08ea59fe9489ffa1d4b1 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 01:24:33 +0300 Subject: [PATCH 06/12] enhance payout account management --- .../integrations/stripe/connect_countries.ex | 5 + lib/algora/payments/payments.ex | 20 ++ lib/algora/payments/schemas/account.ex | 9 + lib/algora_web/components/ui/drawer.ex | 2 +- lib/algora_web/live/user/transactions_live.ex | 273 ++++++++++++++---- .../20250104150728_update_accounts.exs | 8 + 6 files changed, 260 insertions(+), 57 deletions(-) diff --git a/lib/algora/integrations/stripe/connect_countries.ex b/lib/algora/integrations/stripe/connect_countries.ex index d523bae50..58c4d9d81 100644 --- a/lib/algora/integrations/stripe/connect_countries.ex +++ b/lib/algora/integrations/stripe/connect_countries.ex @@ -126,6 +126,11 @@ defmodule Algora.Stripe.ConnectCountries do {"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)) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 113423315..b5ee5c237 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -134,6 +134,15 @@ defmodule Algora.Payments do |> Repo.all() end + @spec get_or_create_account(user_id :: binary(), region :: :US | :EU) :: Account.t() + def get_or_create_account(user_id, region) do + case get_account(user_id, region) do + nil -> create_account(user_id, region) + account -> {:ok, account} + end + end + + @spec get_account(user_id :: binary(), region :: :US | :EU) :: Account.t() | nil def get_account(user_id, region) do Account |> where([a], a.user_id == ^user_id and a.region == ^region) @@ -199,6 +208,10 @@ defmodule Algora.Payments do with {:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id) do attrs = %{ 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), @@ -234,4 +247,11 @@ defmodule Algora.Payments do 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, any()} + 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 0ae8ef9f0..f07781781 100644 --- a/lib/algora/payments/schemas/account.ex +++ b/lib/algora/payments/schemas/account.ex @@ -13,6 +13,10 @@ defmodule Algora.Payments.Account do field :name, :string 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, null: false field :type, Ecto.Enum, values: [:standard, :express], null: false @@ -32,6 +36,10 @@ defmodule Algora.Payments.Account do :provider_meta, :details_submitted, :charges_enabled, + :payouts_enabled, + :payout_interval, + :payout_speed, + :default_currency, :service_agreement, :country, :type, @@ -45,6 +53,7 @@ defmodule Algora.Payments.Account do :provider_meta, :details_submitted, :charges_enabled, + :payouts_enabled, :country, :type, :region, 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/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 11c141a40..f5a3ca856 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -37,10 +37,14 @@ defmodule AlgoraWeb.User.TransactionsLive do account = Payments.get_account(socket.assigns.current_user.id, :US) + dbg(account) + {:ok, socket |> assign(:page_title, "Your transactions") - |> assign(:show_payout_drawer, false) + |> 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()} @@ -50,12 +54,30 @@ defmodule AlgoraWeb.User.TransactionsLive do {:noreply, assign_transactions(socket)} end - def handle_event("show_payout_drawer", _params, socket) do - {:noreply, assign(socket, :show_payout_drawer, true)} + 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, assign(socket, :show_payout_drawer, false)} + {: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 @@ -64,48 +86,42 @@ defmodule AlgoraWeb.User.TransactionsLive do |> PayoutAccountForm.changeset(params) |> Map.put(:action, :validate) - case changeset do - %{valid?: true} = changeset -> - # Get or create Stripe account - account = Payments.get_account(socket.assigns.current_user.id, :US) - - result = - if is_nil(account) do - Payments.create_account(socket.assigns.current_user, %{ - country: changeset.changes.country - }) - else - {:ok, account} - end - - case result do - {:ok, account} -> - if account.charges_enabled do - if account.type == :express do - {:ok, %{url: url}} = Payments.create_login_link(account) - - {:noreply, redirect(socket, external: url)} - else - {:noreply, - socket - |> put_flash(:info, "Account already set up!") - |> assign(:show_payout_drawer, false)} - end - else - {:ok, %{url: url}} = Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) - - {:noreply, redirect(socket, external: url)} - end - - {:error, _reason} -> - {:noreply, - socket - |> put_flash(:error, "Failed to create payout account") - |> assign(:show_payout_drawer, false)} - end - - %{valid?: false} = changeset -> - {:noreply, assign(socket, :payout_account_form, to_form(changeset))} + if changeset.valid? do + with {:ok, account} <- Payments.get_or_create_account(socket.assigns.current_user.id, :US), + {: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 @@ -159,18 +175,27 @@ defmodule AlgoraWeb.User.TransactionsLive do

Your Transactions

View and manage your transaction history

- <.button phx-click="show_payout_drawer"> - <.icon name="tabler-plus" class="w-4 h-4 mr-2 -ml-1" /> - Create payout account - + <%= 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 %>
- <.badge variant={if @account.charges_enabled, do: "success", else: "warning"}> - {if @account.charges_enabled, - do: "Payout account active", - else: "Payout account setup required"} - + <%= 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 %>
@@ -266,7 +291,7 @@ defmodule AlgoraWeb.User.TransactionsLive do - <.drawer show={@show_payout_drawer} on_cancel="close_drawer" direction="right"> + <.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 @@ -299,6 +324,142 @@ defmodule AlgoraWeb.User.TransactionsLive do + <.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 %> +
+ + +
+ +
+ <%= if not @account.details_submitted do %> + <.button phx-click="setup_payout_account"> + Continue onboarding + + <% else %> + <.button phx-click="setup_payout_account" variant="outline"> + Update details + + <% end %> + + <%= if @account.details_submitted and @account.type == :express do %> + <.button phx-click="view_dashboard" variant="outline"> + View dashboard + + <% end %> + + <.button phx-click="show_delete_confirmation" variant="destructive"> + Delete account + +
+
+ + + <.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 diff --git a/priv/repo/migrations/20250104150728_update_accounts.exs b/priv/repo/migrations/20250104150728_update_accounts.exs index 09a83b2e5..fd56fc0a5 100644 --- a/priv/repo/migrations/20250104150728_update_accounts.exs +++ b/priv/repo/migrations/20250104150728_update_accounts.exs @@ -4,6 +4,10 @@ defmodule Algora.Repo.Migrations.UpdateAccounts do def up do alter table(:accounts) do modify :service_agreement, :string, null: true + add :payouts_enabled, :boolean, null: false, default: false + add :payout_interval, :string + add :payout_speed, :integer + add :default_currency, :string end drop index(:accounts, [:user_id]) @@ -13,6 +17,10 @@ defmodule Algora.Repo.Migrations.UpdateAccounts do def down do alter table(:accounts) do modify :service_agreement, :string, null: false + drop :payouts_enabled + drop :payout_interval + drop :payout_speed + drop :default_currency end create index(:accounts, [:user_id]) From b91d80dff0d0bdb7306b66a317752e2f0dddce3a Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 01:53:05 +0300 Subject: [PATCH 07/12] improve manage account drawer --- lib/algora_web/live/user/transactions_live.ex | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index f5a3ca856..01d249cd9 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -336,13 +336,13 @@ defmodule AlgoraWeb.User.TransactionsLive do <.drawer_content class="mt-4">
-
+
<.card> <.card_header> <.card_title>Account Status <.card_content> -
+
Account Type
@@ -384,7 +384,7 @@ defmodule AlgoraWeb.User.TransactionsLive do <.card_title>Payout Settings <.card_content> -
+
Payout Interval
@@ -417,26 +417,26 @@ defmodule AlgoraWeb.User.TransactionsLive do
-
+
+ <.button class="flex-1" phx-click="show_delete_confirmation" variant="destructive"> + Delete account + + <%= if not @account.details_submitted do %> - <.button phx-click="setup_payout_account"> + <.button class="flex-1" phx-click="setup_payout_account"> Continue onboarding <% else %> - <.button phx-click="setup_payout_account" variant="outline"> + <.button class="flex-1" phx-click="setup_payout_account" variant="secondary"> Update details <% end %> <%= if @account.details_submitted and @account.type == :express do %> - <.button phx-click="view_dashboard" variant="outline"> + <.button class="flex-1" phx-click="view_dashboard"> View dashboard <% end %> - - <.button phx-click="show_delete_confirmation" variant="destructive"> - Delete account -
From b76a81a16ca1736ce7d0d52438b917fe5af1610f Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 01:56:07 +0300 Subject: [PATCH 08/12] improve transactions page --- lib/algora_web/live/user/transactions_live.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 01d249cd9..50b3db390 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -41,7 +41,7 @@ defmodule AlgoraWeb.User.TransactionsLive do {: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) @@ -170,9 +170,9 @@ defmodule AlgoraWeb.User.TransactionsLive do ~H"""
-
+
-

Your Transactions

+

Transactions

View and manage your transaction history

<%= if @account do %> @@ -223,7 +223,7 @@ defmodule AlgoraWeb.User.TransactionsLive do
- <.card> + <.card :if={length(@transactions) > 0}> <.card_header> <.card_title>Transaction History From 56cdacff8e627844991dc11aaaf10ee90c5a2b8b Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 02:49:32 +0300 Subject: [PATCH 09/12] misc improvements --- lib/algora/payments/payments.ex | 111 +++++++++--------- .../controllers/stripe_callback_controller.ex | 2 +- lib/algora_web/live/user/transactions_live.ex | 9 +- 3 files changed, 65 insertions(+), 57 deletions(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index b5ee5c237..7269b8f70 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -134,35 +134,36 @@ defmodule Algora.Payments do |> Repo.all() end - @spec get_or_create_account(user_id :: binary(), region :: :US | :EU) :: Account.t() - def get_or_create_account(user_id, region) do - case get_account(user_id, region) do - nil -> create_account(user_id, region) + @spec get_or_create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: + {:ok, Account.t()} | {:error, any()} + def get_or_create_account(user, region, country) do + case get_account(user, region) do + nil -> create_account(user, region, country) account -> {:ok, account} end end - @spec get_account(user_id :: binary(), region :: :US | :EU) :: Account.t() | nil - def get_account(user_id, region) do + @spec get_account(user :: User.t(), region :: :US | :EU) :: Account.t() | nil + def get_account(user, region) do Account - |> where([a], a.user_id == ^user_id and a.region == ^region) + |> where([a], a.user_id == ^user.id and a.region == ^region) |> Repo.one() end - @spec create_account(user :: User.t(), attrs :: %{optional(atom()) => any()}) :: + @spec create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: {:ok, Account.t()} | {:error, any()} - def create_account(user, attrs) do - attrs = Map.put(attrs, :type, ConnectCountries.account_type(attrs.country)) + def create_account(user, region, country) do + type = ConnectCountries.account_type(country) - with {:ok, stripe_account} <- create_stripe_account(attrs) do + 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: attrs.type, - region: :US, + type: type, + region: region, user_id: user.id, - country: attrs.country + country: country } %Account{} @@ -171,12 +172,12 @@ defmodule Algora.Payments do end end - @spec create_stripe_account(attrs :: %{optional(atom()) => any()}) :: + @spec create_stripe_account(attrs :: any()) :: {:ok, Stripe.Account.t()} | {:error, Stripe.Error.t()} defp create_stripe_account(%{country: country, type: type}) do - case Stripe.Account.create(%{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: type}) + {:error, _reason} -> Stripe.Account.create(%{type: to_string(type)}) end end @@ -192,58 +193,60 @@ defmodule Algora.Payments do end @spec create_login_link(account :: Account.t()) :: - {:ok, Stripe.Account.t()} | {:error, Stripe.Error.t()} + {:ok, Stripe.LoginLink.t()} | {:error, Stripe.Error.t()} def create_login_link(account) do - Stripe.Account.create_login_link(account.provider_id, %{}) + Stripe.LoginLink.create(account.provider_id, %{}) end - @spec refresh_stripe_account(user_id :: binary()) :: - {:ok, Account.t()} | {:error, :account_not_found} | {:error, any()} - def refresh_stripe_account(user_id) do - case get_account(user_id, :US) do + @spec refresh_stripe_account(user :: User.t()) :: + {:ok, Account.t()} | {:error, any()} + def refresh_stripe_account(user) do + case get_account(user, :US) do nil -> {:error, :account_not_found} account -> - with {:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id) do - attrs = %{ - 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), - provider_meta: Util.normalize_struct(stripe_account) - } - - account - |> Account.changeset(attrs) - |> Repo.update() - |> case do - {:ok, updated_account} -> - if stripe_account.charges_enabled do - account.user_id - |> Accounts.get_user!() - |> Accounts.update_settings(%{country: stripe_account.country}) - end - - # TODO: enqueue pending transfers - - {:ok, updated_account} - - error -> - error - end + case Stripe.Account.retrieve(account.provider_id) do + {:ok, stripe_account} -> + attrs = %{ + 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) + } + + res = + account + |> Account.changeset(attrs) + |> Repo.update() + + user = Accounts.get_user(account.user_id) + + if user && stripe_account.charges_enabled do + Accounts.update_settings(user, %{country: stripe_account.country}) + end + + res + + {:error, error} -> + {:error, error} end 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 diff --git a/lib/algora_web/controllers/stripe_callback_controller.ex b/lib/algora_web/controllers/stripe_callback_controller.ex index a07a3a1a9..f3a424965 100644 --- a/lib/algora_web/controllers/stripe_callback_controller.ex +++ b/lib/algora_web/controllers/stripe_callback_controller.ex @@ -14,7 +14,7 @@ defmodule AlgoraWeb.StripeCallbackController do |> halt() current_user -> - case Payments.refresh_stripe_account(current_user.id) do + case Payments.refresh_stripe_account(current_user) do {:ok, _account} -> redirect(conn, to: ~p"/user/transactions") diff --git a/lib/algora_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 50b3db390..3322ead54 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -3,6 +3,8 @@ 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 @@ -35,7 +37,7 @@ defmodule AlgoraWeb.User.TransactionsLive do Payments.subscribe() end - account = Payments.get_account(socket.assigns.current_user.id, :US) + account = Payments.get_account(socket.assigns.current_user, :US) dbg(account) @@ -86,8 +88,11 @@ defmodule AlgoraWeb.User.TransactionsLive do |> PayoutAccountForm.changeset(params) |> Map.put(:action, :validate) + country = get_change(changeset, :country) + if changeset.valid? do - with {:ok, account} <- Payments.get_or_create_account(socket.assigns.current_user.id, :US), + with {:ok, account} <- + Payments.get_or_create_account(socket.assigns.current_user, :US, country), {:ok, %{url: url}} <- Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) do {:noreply, redirect(socket, external: url)} else From 058b515f1c4ae6307e23156fd8b77bf7e62684b2 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 03:50:08 +0300 Subject: [PATCH 10/12] misc improvements --- lib/algora/payments/payments.ex | 92 +++++++++---------- lib/algora/repo.ex | 2 +- lib/algora_web/live/user/transactions_live.ex | 6 +- 3 files changed, 45 insertions(+), 55 deletions(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index 7269b8f70..a3d457ee6 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -134,24 +134,23 @@ defmodule Algora.Payments do |> Repo.all() end - @spec get_or_create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: - {:ok, Account.t()} | {:error, any()} - def get_or_create_account(user, region, country) do - case get_account(user, region) do - nil -> create_account(user, region, country) - account -> {:ok, account} + @spec fetch_or_create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} + def fetch_or_create_account(user, region, country) do + case fetch_account(user, region) do + {:ok, account} -> {:ok, account} + {:error, :not_found} -> create_account(user, region, country) end end - @spec get_account(user :: User.t(), region :: :US | :EU) :: Account.t() | nil - def get_account(user, region) do - Account - |> where([a], a.user_id == ^user.id and a.region == ^region) - |> Repo.one() + @spec fetch_account(user :: User.t(), region :: :US | :EU) :: + {:ok, Account.t()} | {:error, :not_found} + def fetch_account(user, region) do + Repo.fetch_by(Account, user_id: user.id, region: region) end @spec create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: - {:ok, Account.t()} | {:error, any()} + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} def create_account(user, region, country) do type = ConnectCountries.account_type(country) @@ -172,7 +171,7 @@ defmodule Algora.Payments do end end - @spec create_stripe_account(attrs :: any()) :: + @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 @@ -198,46 +197,39 @@ defmodule Algora.Payments 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, any()} + {:ok, Account.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found} | {:error, Stripe.Error.t()} def refresh_stripe_account(user) do - case get_account(user, :US) do - nil -> - {:error, :account_not_found} - - account -> - case Stripe.Account.retrieve(account.provider_id) do - {:ok, stripe_account} -> - attrs = %{ - 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) - } - - res = - account - |> Account.changeset(attrs) - |> Repo.update() - - user = Accounts.get_user(account.user_id) - - if user && stripe_account.charges_enabled do - Accounts.update_settings(user, %{country: stripe_account.country}) - end + with {:ok, account} <- fetch_account(user, :US), + {: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) - res + if user && stripe_account.charges_enabled do + Accounts.update_settings(user, %{country: stripe_account.country}) + end - {:error, error} -> - {:error, error} - end + {:ok, updated_account} end end @@ -251,7 +243,7 @@ defmodule Algora.Payments do if is_nil(capabilities[:card_payments]), do: "recipient", else: "full" end - @spec delete_account(account :: Account.t()) :: {:ok, Account.t()} | {:error, any()} + @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) 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/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index 3322ead54..cc0b4b22b 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -37,9 +37,7 @@ defmodule AlgoraWeb.User.TransactionsLive do Payments.subscribe() end - account = Payments.get_account(socket.assigns.current_user, :US) - - dbg(account) + {:ok, account} = Payments.fetch_account(socket.assigns.current_user, :US) {:ok, socket @@ -92,7 +90,7 @@ defmodule AlgoraWeb.User.TransactionsLive do if changeset.valid? do with {:ok, account} <- - Payments.get_or_create_account(socket.assigns.current_user, :US, country), + Payments.fetch_or_create_account(socket.assigns.current_user, :US, country), {:ok, %{url: url}} <- Payments.create_account_link(account, AlgoraWeb.Endpoint.url()) do {:noreply, redirect(socket, external: url)} else From a64dd708123e2d5879533242107dbeaa14a35295 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 04:18:54 +0300 Subject: [PATCH 11/12] fix dialyzer error --- lib/algora/payments/payments.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index a3d457ee6..e026aebfe 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -221,7 +221,7 @@ defmodule Algora.Payments do {: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, :US), - {:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id), + {: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) From 05eaaefc4685bcc8129c5df04820ed728ae3d045 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 5 Jan 2025 04:30:21 +0300 Subject: [PATCH 12/12] drop region param --- lib/algora/payments/payments.ex | 21 ++++++++-------- lib/algora/payments/schemas/account.ex | 4 ---- lib/algora/payments/schemas/customer.ex | 5 ++-- lib/algora_web/live/user/transactions_live.ex | 4 ++-- ...4150728_update_accounts_and_customers.exs} | 24 +++++++++++++------ test/support/factory.ex | 4 +--- 6 files changed, 32 insertions(+), 30 deletions(-) rename priv/repo/migrations/{20250104150728_update_accounts.exs => 20250104150728_update_accounts_and_customers.exs} (51%) diff --git a/lib/algora/payments/payments.ex b/lib/algora/payments/payments.ex index e026aebfe..053f8b805 100644 --- a/lib/algora/payments/payments.ex +++ b/lib/algora/payments/payments.ex @@ -134,24 +134,24 @@ defmodule Algora.Payments do |> Repo.all() end - @spec fetch_or_create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: + @spec fetch_or_create_account(user :: User.t(), country :: String.t()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def fetch_or_create_account(user, region, country) do - case fetch_account(user, region) do + def fetch_or_create_account(user, country) do + case fetch_account(user) do {:ok, account} -> {:ok, account} - {:error, :not_found} -> create_account(user, region, country) + {:error, :not_found} -> create_account(user, country) end end - @spec fetch_account(user :: User.t(), region :: :US | :EU) :: + @spec fetch_account(user :: User.t()) :: {:ok, Account.t()} | {:error, :not_found} - def fetch_account(user, region) do - Repo.fetch_by(Account, user_id: user.id, region: region) + def fetch_account(user) do + Repo.fetch_by(Account, user_id: user.id) end - @spec create_account(user :: User.t(), region :: :US | :EU, country :: String.t()) :: + @spec create_account(user :: User.t(), country :: String.t()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()} - def create_account(user, region, country) do + def create_account(user, country) do type = ConnectCountries.account_type(country) with {:ok, stripe_account} <- create_stripe_account(%{country: country, type: type}) do @@ -160,7 +160,6 @@ defmodule Algora.Payments do provider_id: stripe_account.id, provider_meta: Util.normalize_struct(stripe_account), type: type, - region: region, user_id: user.id, country: country } @@ -220,7 +219,7 @@ defmodule Algora.Payments do @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, :US), + 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) diff --git a/lib/algora/payments/schemas/account.ex b/lib/algora/payments/schemas/account.ex index f07781781..e38c1d041 100644 --- a/lib/algora/payments/schemas/account.ex +++ b/lib/algora/payments/schemas/account.ex @@ -20,7 +20,6 @@ defmodule Algora.Payments.Account do field :service_agreement, :string field :country, :string, null: false field :type, Ecto.Enum, values: [:standard, :express], null: false - field :region, Ecto.Enum, values: [:US, :EU], null: false field :stale, :boolean, default: false, null: false belongs_to :user, Algora.Accounts.User, null: false @@ -43,7 +42,6 @@ defmodule Algora.Payments.Account do :service_agreement, :country, :type, - :region, :stale, :user_id ]) @@ -56,12 +54,10 @@ defmodule Algora.Payments.Account do :payouts_enabled, :country, :type, - :region, :stale, :user_id ]) |> validate_inclusion(:type, [:standard, :express]) - |> validate_inclusion(:region, [:US, :EU]) |> validate_inclusion(:country, Stripe.ConnectCountries.list_codes()) |> foreign_key_constraint(:user_id) |> generate_id() 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_web/live/user/transactions_live.ex b/lib/algora_web/live/user/transactions_live.ex index cc0b4b22b..8cb3fb5db 100644 --- a/lib/algora_web/live/user/transactions_live.ex +++ b/lib/algora_web/live/user/transactions_live.ex @@ -37,7 +37,7 @@ defmodule AlgoraWeb.User.TransactionsLive do Payments.subscribe() end - {:ok, account} = Payments.fetch_account(socket.assigns.current_user, :US) + {:ok, account} = Payments.fetch_account(socket.assigns.current_user) {:ok, socket @@ -90,7 +90,7 @@ defmodule AlgoraWeb.User.TransactionsLive do if changeset.valid? do with {:ok, account} <- - Payments.fetch_or_create_account(socket.assigns.current_user, :US, country), + 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 diff --git a/priv/repo/migrations/20250104150728_update_accounts.exs b/priv/repo/migrations/20250104150728_update_accounts_and_customers.exs similarity index 51% rename from priv/repo/migrations/20250104150728_update_accounts.exs rename to priv/repo/migrations/20250104150728_update_accounts_and_customers.exs index fd56fc0a5..01054fe1c 100644 --- a/priv/repo/migrations/20250104150728_update_accounts.exs +++ b/priv/repo/migrations/20250104150728_update_accounts_and_customers.exs @@ -1,4 +1,4 @@ -defmodule Algora.Repo.Migrations.UpdateAccounts do +defmodule Algora.Repo.Migrations.UpdateAccountsAndCustomers do use Ecto.Migration def up do @@ -8,22 +8,32 @@ defmodule Algora.Repo.Migrations.UpdateAccounts do add :payout_interval, :string add :payout_speed, :integer add :default_currency, :string + remove :region + end + + alter table(:customers) do + remove :region end drop index(:accounts, [:user_id]) - create unique_index(:accounts, [:user_id, :region]) + create unique_index(:accounts, [:user_id]) end def down do alter table(:accounts) do modify :service_agreement, :string, null: false - drop :payouts_enabled - drop :payout_interval - drop :payout_speed - drop :default_currency + remove :payouts_enabled + remove :payout_interval + remove :payout_speed + remove :default_currency + add :region, :string, null: false + end + + alter table(:customers) do + add :region, :string, null: false end create index(:accounts, [:user_id]) - drop unique_index(:accounts, [:user_id, :region]) + drop unique_index(:accounts, [:user_id]) end end diff --git a/test/support/factory.ex b/test/support/factory.ex index b1d42068f..a09b2c876 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -81,8 +81,7 @@ defmodule Algora.Factory do provider: "stripe", provider_id: "cus_1234567890", provider_meta: %{}, - name: "Pied Piper", - region: :US + name: "Pied Piper" } end @@ -106,7 +105,6 @@ defmodule Algora.Factory do service_agreement: "recipient", country: "US", type: :express, - region: :US, stale: false } end