Skip to content

Commit 42c8094

Browse files
authored
feat: create payout accounts (#17)
* add create payout account form * add stripe connect flow * handle edge case while determining service agreement * set payout account type based on country * add payout account status indicator * enhance payout account management * improve manage account drawer * improve transactions page * misc improvements * misc improvements * fix dialyzer error * drop region param
1 parent c6ffb3e commit 42c8094

File tree

12 files changed

+678
-26
lines changed

12 files changed

+678
-26
lines changed

lib/algora/accounts/schemas/user.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ defmodule Algora.Accounts.User do
236236
:need_avatar,
237237
:website_url,
238238
:bio,
239+
:country,
239240
:location,
240241
:timezone
241242
])
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
defmodule Algora.Stripe.ConnectCountries do
2+
@moduledoc false
3+
4+
@spec list() :: [{String.t(), String.t()}]
5+
def list,
6+
do: [
7+
{"Albania", "AL"},
8+
{"Algeria", "DZ"},
9+
{"Angola", "AO"},
10+
{"Antigua and Barbuda", "AG"},
11+
{"Argentina", "AR"},
12+
{"Armenia", "AM"},
13+
{"Australia", "AU"},
14+
{"Austria", "AT"},
15+
{"Azerbaijan", "AZ"},
16+
{"Bahamas", "BS"},
17+
{"Bahrain", "BH"},
18+
{"Bangladesh", "BD"},
19+
{"Belgium", "BE"},
20+
{"Benin", "BJ"},
21+
{"Bhutan", "BT"},
22+
{"Bolivia", "BO"},
23+
{"Bosnia and Herzegovina", "BA"},
24+
{"Botswana", "BW"},
25+
{"Brazil", "BR"},
26+
{"Brunei", "BN"},
27+
{"Bulgaria", "BG"},
28+
{"Cambodia", "KH"},
29+
{"Canada", "CA"},
30+
{"Chile", "CL"},
31+
{"Colombia", "CO"},
32+
{"Costa Rica", "CR"},
33+
{"Croatia", "HR"},
34+
{"Cyprus", "CY"},
35+
{"Czech Republic", "CZ"},
36+
{"Denmark", "DK"},
37+
{"Dominican Republic", "DO"},
38+
{"Ecuador", "EC"},
39+
{"Egypt", "EG"},
40+
{"El Salvador", "SV"},
41+
{"Estonia", "EE"},
42+
{"Ethiopia", "ET"},
43+
{"Finland", "FI"},
44+
{"France", "FR"},
45+
{"Gabon", "GA"},
46+
{"Gambia", "GM"},
47+
{"Germany", "DE"},
48+
{"Ghana", "GH"},
49+
{"Gibraltar", "GI"},
50+
{"Greece", "GR"},
51+
{"Guatemala", "GT"},
52+
{"Guyana", "GY"},
53+
{"Hong Kong", "HK"},
54+
{"Hungary", "HU"},
55+
{"Iceland", "IS"},
56+
{"India", "IN"},
57+
{"Indonesia", "ID"},
58+
{"Ireland", "IE"},
59+
{"Israel", "IL"},
60+
{"Italy", "IT"},
61+
{"Ivory Coast", "CI"},
62+
{"Jamaica", "JM"},
63+
{"Japan", "JP"},
64+
{"Jordan", "JO"},
65+
{"Kazakhstan", "KZ"},
66+
{"Kenya", "KE"},
67+
{"Kuwait", "KW"},
68+
{"Laos", "LA"},
69+
{"Latvia", "LV"},
70+
{"Liechtenstein", "LI"},
71+
{"Lithuania", "LT"},
72+
{"Luxembourg", "LU"},
73+
{"Macao", "MO"},
74+
{"Macedonia", "MK"},
75+
{"Madagascar", "MG"},
76+
{"Malaysia", "MY"},
77+
{"Malta", "MT"},
78+
{"Mauritius", "MU"},
79+
{"Mexico", "MX"},
80+
{"Moldova", "MD"},
81+
{"Monaco", "MC"},
82+
{"Mongolia", "MN"},
83+
{"Morocco", "MA"},
84+
{"Mozambique", "MZ"},
85+
{"Namibia", "NA"},
86+
{"Netherlands", "NL"},
87+
{"New Zealand", "NZ"},
88+
{"Nigeria", "NG"},
89+
{"Norway", "NO"},
90+
{"Oman", "OM"},
91+
{"Pakistan", "PK"},
92+
{"Panama", "PA"},
93+
{"Paraguay", "PY"},
94+
{"Peru", "PE"},
95+
{"Philippines", "PH"},
96+
{"Poland", "PL"},
97+
{"Portugal", "PT"},
98+
{"Qatar", "QA"},
99+
{"Romania", "RO"},
100+
{"Rwanda", "RW"},
101+
{"Saint Lucia", "LC"},
102+
{"San Marino", "SM"},
103+
{"Saudi Arabia", "SA"},
104+
{"Senegal", "SN"},
105+
{"Serbia", "RS"},
106+
{"Singapore", "SG"},
107+
{"Slovakia", "SK"},
108+
{"Slovenia", "SI"},
109+
{"South Africa", "ZA"},
110+
{"South Korea", "KR"},
111+
{"Spain", "ES"},
112+
{"Sri Lanka", "LK"},
113+
{"Sweden", "SE"},
114+
{"Switzerland", "CH"},
115+
{"Taiwan", "TW"},
116+
{"Tanzania", "TZ"},
117+
{"Thailand", "TH"},
118+
{"Trinidad and Tobago", "TT"},
119+
{"Tunisia", "TN"},
120+
{"Turkey", "TR"},
121+
{"United Arab Emirates", "AE"},
122+
{"United Kingdom", "GB"},
123+
{"United States", "US"},
124+
{"Uruguay", "UY"},
125+
{"Uzbekistan", "UZ"},
126+
{"Vietnam", "VN"}
127+
]
128+
129+
@spec from_code(String.t()) :: String.t()
130+
def from_code(code) do
131+
list() |> Enum.find(&(elem(&1, 1) == code)) |> elem(0) || code
132+
end
133+
134+
@spec list_codes() :: [String.t()]
135+
def list_codes, do: Enum.map(list(), &elem(&1, 1))
136+
137+
@spec account_type(String.t()) :: :standard | :express
138+
def account_type("BR"), do: :standard
139+
def account_type(_), do: :express
140+
end

lib/algora/payments/payments.ex

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ defmodule Algora.Payments do
22
@moduledoc false
33
import Ecto.Query
44

5+
alias Algora.Accounts
6+
alias Algora.Accounts.User
7+
alias Algora.Payments.Account
58
alias Algora.Payments.Customer
69
alias Algora.Payments.PaymentMethod
710
alias Algora.Payments.Transaction
811
alias Algora.Repo
12+
alias Algora.Stripe.ConnectCountries
13+
alias Algora.Util
914

1015
require Logger
1116

@@ -128,4 +133,119 @@ defmodule Algora.Payments do
128133
|> order_by([t], desc: t.inserted_at)
129134
|> Repo.all()
130135
end
136+
137+
@spec fetch_or_create_account(user :: User.t(), country :: String.t()) ::
138+
{:ok, Account.t()} | {:error, Ecto.Changeset.t()}
139+
def fetch_or_create_account(user, country) do
140+
case fetch_account(user) do
141+
{:ok, account} -> {:ok, account}
142+
{:error, :not_found} -> create_account(user, country)
143+
end
144+
end
145+
146+
@spec fetch_account(user :: User.t()) ::
147+
{:ok, Account.t()} | {:error, :not_found}
148+
def fetch_account(user) do
149+
Repo.fetch_by(Account, user_id: user.id)
150+
end
151+
152+
@spec create_account(user :: User.t(), country :: String.t()) ::
153+
{:ok, Account.t()} | {:error, Ecto.Changeset.t()}
154+
def create_account(user, country) do
155+
type = ConnectCountries.account_type(country)
156+
157+
with {:ok, stripe_account} <- create_stripe_account(%{country: country, type: type}) do
158+
attrs = %{
159+
provider: "stripe",
160+
provider_id: stripe_account.id,
161+
provider_meta: Util.normalize_struct(stripe_account),
162+
type: type,
163+
user_id: user.id,
164+
country: country
165+
}
166+
167+
%Account{}
168+
|> Account.changeset(attrs)
169+
|> Repo.insert()
170+
end
171+
end
172+
173+
@spec create_stripe_account(attrs :: map()) ::
174+
{:ok, Stripe.Account.t()} | {:error, Stripe.Error.t()}
175+
defp create_stripe_account(%{country: country, type: type}) do
176+
case Stripe.Account.create(%{country: country, type: to_string(type)}) do
177+
{:ok, account} -> {:ok, account}
178+
{:error, _reason} -> Stripe.Account.create(%{type: to_string(type)})
179+
end
180+
end
181+
182+
@spec create_account_link(account :: Account.t(), base_url :: String.t()) ::
183+
{:ok, Stripe.AccountLink.t()} | {:error, Stripe.Error.t()}
184+
def create_account_link(account, base_url) do
185+
Stripe.AccountLink.create(%{
186+
account: account.provider_id,
187+
refresh_url: "#{base_url}/callbacks/stripe/refresh",
188+
return_url: "#{base_url}/callbacks/stripe/return",
189+
type: "account_onboarding"
190+
})
191+
end
192+
193+
@spec create_login_link(account :: Account.t()) ::
194+
{:ok, Stripe.LoginLink.t()} | {:error, Stripe.Error.t()}
195+
def create_login_link(account) do
196+
Stripe.LoginLink.create(account.provider_id, %{})
197+
end
198+
199+
@spec update_account(account :: Account.t(), stripe_account :: Stripe.Account.t()) ::
200+
{:ok, Account.t()} | {:error, Ecto.Changeset.t()}
201+
def update_account(account, stripe_account) do
202+
account
203+
|> Account.changeset(%{
204+
provider: "stripe",
205+
provider_id: stripe_account.id,
206+
provider_meta: Util.normalize_struct(stripe_account),
207+
charges_enabled: stripe_account.charges_enabled,
208+
payouts_enabled: stripe_account.payouts_enabled,
209+
payout_interval: stripe_account.settings.payouts.schedule.interval,
210+
payout_speed: stripe_account.settings.payouts.schedule.delay_days,
211+
default_currency: stripe_account.default_currency,
212+
details_submitted: stripe_account.details_submitted,
213+
country: stripe_account.country,
214+
service_agreement: get_service_agreement(stripe_account)
215+
})
216+
|> Repo.update()
217+
end
218+
219+
@spec refresh_stripe_account(user :: User.t()) ::
220+
{:ok, Account.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found} | {:error, Stripe.Error.t()}
221+
def refresh_stripe_account(user) do
222+
with {:ok, account} <- fetch_account(user),
223+
{:ok, stripe_account} <- Stripe.Account.retrieve(account.provider_id, []),
224+
{:ok, updated_account} <- update_account(account, stripe_account) do
225+
user = Accounts.get_user(account.user_id)
226+
227+
if user && stripe_account.charges_enabled do
228+
Accounts.update_settings(user, %{country: stripe_account.country})
229+
end
230+
231+
{:ok, updated_account}
232+
end
233+
end
234+
235+
@spec get_service_agreement(account :: Stripe.Account.t()) :: String.t()
236+
defp get_service_agreement(%{tos_acceptance: %{service_agreement: agreement}} = _account) when not is_nil(agreement) do
237+
agreement
238+
end
239+
240+
@spec get_service_agreement(account :: Stripe.Account.t()) :: String.t()
241+
defp get_service_agreement(%{capabilities: capabilities}) do
242+
if is_nil(capabilities[:card_payments]), do: "recipient", else: "full"
243+
end
244+
245+
@spec delete_account(account :: Account.t()) :: {:ok, Account.t()} | {:error, Ecto.Changeset.t()}
246+
def delete_account(account) do
247+
with {:ok, _stripe_account} <- Stripe.Account.delete(account.provider_id) do
248+
Repo.delete(account)
249+
end
250+
end
131251
end

lib/algora/payments/schemas/account.ex

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@ defmodule Algora.Payments.Account do
22
@moduledoc false
33
use Algora.Schema
44

5+
alias Algora.Stripe
6+
57
@derive {Inspect, except: [:provider_meta]}
68
typed_schema "accounts" do
7-
field :provider, :string
8-
field :provider_id, :string
9-
field :provider_meta, :map
9+
field :provider, :string, null: false
10+
field :provider_id, :string, null: false
11+
field :provider_meta, :map, null: false
1012

1113
field :name, :string
12-
field :details_submitted, :boolean, default: false
13-
field :charges_enabled, :boolean, default: false
14+
field :details_submitted, :boolean, default: false, null: false
15+
field :charges_enabled, :boolean, default: false, null: false
16+
field :payouts_enabled, :boolean, default: false, null: false
17+
field :payout_interval, :string
18+
field :payout_speed, :integer
19+
field :default_currency, :string
1420
field :service_agreement, :string
15-
field :country, :string
16-
field :type, Ecto.Enum, values: [:standard, :express]
17-
field :region, Ecto.Enum, values: [:US, :EU]
18-
field :stale, :boolean, default: false
21+
field :country, :string, null: false
22+
field :type, Ecto.Enum, values: [:standard, :express], null: false
23+
field :stale, :boolean, default: false, null: false
1924

20-
belongs_to :user, Algora.Accounts.User
25+
belongs_to :user, Algora.Accounts.User, null: false
2126

2227
timestamps()
2328
end
@@ -30,12 +35,31 @@ defmodule Algora.Payments.Account do
3035
:provider_meta,
3136
:details_submitted,
3237
:charges_enabled,
38+
:payouts_enabled,
39+
:payout_interval,
40+
:payout_speed,
41+
:default_currency,
3342
:service_agreement,
3443
:country,
3544
:type,
36-
:region,
37-
:stale
45+
:stale,
46+
:user_id
47+
])
48+
|> validate_required([
49+
:provider,
50+
:provider_id,
51+
:provider_meta,
52+
:details_submitted,
53+
:charges_enabled,
54+
:payouts_enabled,
55+
:country,
56+
:type,
57+
:stale,
58+
:user_id
3859
])
39-
|> validate_required([:provider, :provider_id, :provider_meta])
60+
|> validate_inclusion(:type, [:standard, :express])
61+
|> validate_inclusion(:country, Stripe.ConnectCountries.list_codes())
62+
|> foreign_key_constraint(:user_id)
63+
|> generate_id()
4064
end
4165
end

lib/algora/payments/schemas/customer.ex

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ defmodule Algora.Payments.Customer do
99
field :provider_meta, :map
1010

1111
field :name, :string
12-
field :region, Ecto.Enum, values: [:US, :EU]
1312

1413
belongs_to :user, Algora.Accounts.User
1514

@@ -22,8 +21,8 @@ defmodule Algora.Payments.Customer do
2221

2322
def changeset(customer, attrs) do
2423
customer
25-
|> cast(attrs, [:user_id, :provider, :provider_id, :provider_meta, :name, :region])
26-
|> validate_required([:user_id, :provider, :provider_id, :provider_meta, :name, :region])
24+
|> cast(attrs, [:user_id, :provider, :provider_id, :provider_meta, :name])
25+
|> validate_required([:user_id, :provider, :provider_id, :provider_meta, :name])
2726
|> unique_constraint(:user_id)
2827
end
2928
end

lib/algora/repo.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ defmodule Algora.Repo do
3737
fetch_one(query, opts)
3838
end
3939

40-
@spec fetch_by(Ecto.Queryable.t(), Keyword.t(), Keyword.t()) ::
40+
@spec fetch_by(Ecto.Queryable.t(), Keyword.t() | map(), Keyword.t()) ::
4141
{:ok, struct()} | {:error, :not_found}
4242
def fetch_by(queryable, clauses, opts \\ []) do
4343
query =

lib/algora_web/components/ui/drawer.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do
4545
"fixed z-50 transform border bg-background transition-transform duration-300 ease-in-out",
4646
case @direction do
4747
"bottom" -> "inset-x-0 bottom-0 rounded-t-xl"
48-
"right" -> "inset-y-0 right-0 h-full max-w-lg"
48+
"right" -> "inset-y-0 right-0 h-full max-w-lg w-full"
4949
end,
5050
case @direction do
5151
"bottom" -> if(@show, do: "translate-y-0", else: "translate-y-full")

0 commit comments

Comments
 (0)