Skip to content

Commit 8b47be9

Browse files
authored
feat: auto-pay on merge (#51)
1 parent 80d4dfa commit 8b47be9

File tree

6 files changed

+374
-40
lines changed

6 files changed

+374
-40
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ defmodule Algora.Bounties do
462462
recipient = opts[:recipient]
463463
claims = opts[:claims] || []
464464

465-
description = if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}")
465+
description = if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}")
466466

467467
platform_fee_pct = FeeTier.calculate_fee_percentage(Money.zero(:USD))
468468
transaction_fee_pct = Payments.get_transaction_fee_pct()
@@ -563,6 +563,73 @@ defmodule Algora.Bounties do
563563
end)
564564
end
565565

566+
@spec create_invoice(
567+
%{owner: User.t(), amount: Money.t()},
568+
opts :: [
569+
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
570+
tip_id: String.t(),
571+
bounty_id: String.t(),
572+
claims: [Claim.t()],
573+
recipient: User.t()
574+
]
575+
) ::
576+
{:ok, Stripe.Invoice.t()} | {:error, atom()}
577+
def create_invoice(%{owner: owner, amount: amount}, opts \\ []) do
578+
tx_group_id = Nanoid.generate()
579+
580+
line_items =
581+
generate_line_items(%{amount: amount},
582+
ticket_ref: opts[:ticket_ref],
583+
recipient: opts[:recipient],
584+
claims: opts[:claims]
585+
)
586+
587+
gross_amount = LineItem.gross_amount(line_items)
588+
589+
Repo.transact(fn ->
590+
with {:ok, _charge} <-
591+
initialize_charge(%{
592+
id: Nanoid.generate(),
593+
tip_id: opts[:tip_id],
594+
bounty_id: opts[:bounty_id],
595+
claim_id: nil,
596+
user_id: owner.id,
597+
gross_amount: gross_amount,
598+
net_amount: amount,
599+
total_fee: Money.sub!(gross_amount, amount),
600+
line_items: line_items,
601+
group_id: tx_group_id
602+
}),
603+
{:ok, _transactions} <-
604+
create_transaction_pairs(%{
605+
claims: opts[:claims] || [],
606+
tip_id: opts[:tip_id],
607+
bounty_id: opts[:bounty_id],
608+
claim_id: nil,
609+
amount: amount,
610+
creator_id: owner.id,
611+
group_id: tx_group_id
612+
}),
613+
{:ok, customer} <- Payments.fetch_or_create_customer(owner),
614+
{:ok, invoice} <-
615+
Algora.Stripe.create_invoice(%{
616+
auto_advance: false,
617+
customer: customer.provider_id
618+
}),
619+
{:ok, _line_items} <-
620+
line_items
621+
|> Enum.map(&LineItem.to_invoice_item(&1, invoice, customer))
622+
|> Enum.reduce_while({:ok, []}, fn params, {:ok, acc} ->
623+
case Algora.Stripe.create_invoice_item(params) do
624+
{:ok, item} -> {:cont, {:ok, [item | acc]}}
625+
{:error, error} -> {:halt, {:error, error}}
626+
end
627+
end) do
628+
{:ok, invoice}
629+
end
630+
end)
631+
end
632+
566633
defp initialize_charge(%{
567634
id: id,
568635
tip_id: tip_id,

lib/algora/bounties/schemas/line_item.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ defmodule Algora.Bounties.LineItem do
3232
}
3333
end
3434

35+
def to_invoice_item(line_item, invoice, customer) do
36+
%{
37+
invoice: invoice.id,
38+
customer: customer.provider_id,
39+
amount: MoneyUtils.to_minor_units(line_item.amount),
40+
currency: to_string(line_item.amount.currency),
41+
description: if(line_item.description, do: line_item.title <> " - " <> line_item.description, else: line_item.title)
42+
}
43+
end
44+
3545
def gross_amount(line_items) do
3646
Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> Money.add!(acc, item.amount) end)
3747
end

lib/algora/shared/util.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ defmodule Algora.Util do
8080
|> Kernel.<>("%")
8181
end
8282

83+
def normalize_struct(%Money{} = money) do
84+
%{
85+
amount: Decimal.to_string(money.amount),
86+
currency: money.currency
87+
}
88+
end
89+
8390
def normalize_struct(struct) when is_struct(struct) do
8491
struct
8592
|> Map.from_struct()

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 188 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
defmodule AlgoraWeb.Webhooks.GithubController do
22
use AlgoraWeb, :controller
33

4+
import Ecto.Query
5+
46
alias Algora.Accounts
57
alias Algora.Bounties
8+
alias Algora.Bounties.Bounty
9+
alias Algora.Bounties.Claim
610
alias Algora.Github
711
alias Algora.Github.Webhook
812
alias Algora.Repo
913
alias Algora.Workspace
1014
alias Algora.Workspace.CommandResponse
15+
alias Algora.Workspace.Installation
1116

1217
require Logger
1318

1419
# TODO: persist & alert about failed deliveries
1520
# TODO: auto-retry failed deliveries with exponential backoff
1621

1722
def new(conn, params) do
18-
with {:ok, webhook} <- Webhook.new(conn),
23+
with {:ok, %{event: event} = webhook} <- Webhook.new(conn),
1924
:ok <- ensure_human_author(webhook, params),
20-
{:ok, _} <- process_commands(webhook, params) do
25+
author = get_author(event, params),
26+
body = get_body(event, params),
27+
event_action = join_event_action(event, params),
28+
{:ok, _} <- process_commands(webhook, event_action, author, body, params),
29+
:ok <- process_event(event_action, params) do
2130
conn |> put_status(:accepted) |> json(%{status: "ok"})
2231
else
2332
{:error, :bot_event} ->
@@ -32,10 +41,14 @@ defmodule AlgoraWeb.Webhooks.GithubController do
3241
{:error, reason} ->
3342
Logger.error("Error processing webhook: #{inspect(reason)}")
3443
conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"})
44+
45+
error ->
46+
Logger.error("Error processing webhook: #{inspect(error)}")
47+
conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"})
3548
end
3649
rescue
3750
e ->
38-
Logger.error("Unexpected error: #{inspect(e)}")
51+
Logger.error(Exception.format(:error, e, __STACKTRACE__))
3952
conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"})
4053
end
4154

@@ -63,6 +76,161 @@ defmodule AlgoraWeb.Webhooks.GithubController do
6376

6477
defp get_permissions(_author, _params), do: {:error, :invalid_params}
6578

79+
def process_event(event_action, %{"pull_request" => %{"merged_at" => nil}})
80+
when event_action in ["pull_request.closed"] do
81+
:ok
82+
end
83+
84+
def process_event(event_action, %{
85+
"repository" => repository,
86+
"pull_request" => pull_request,
87+
"installation" => installation
88+
})
89+
when event_action in ["pull_request.closed"] do
90+
with {:ok, token} <- Github.get_installation_token(installation["id"]),
91+
{:ok, source} <-
92+
Workspace.ensure_ticket(token, repository["owner"]["login"], repository["name"], pull_request["number"]) do
93+
claims =
94+
case Repo.one(
95+
from c in Claim,
96+
join: s in assoc(c, :source),
97+
join: u in assoc(c, :user),
98+
where: s.id == ^source.id,
99+
where: u.provider == "github",
100+
where: u.provider_id == ^to_string(pull_request["user"]["id"]),
101+
order_by: [asc: c.inserted_at],
102+
limit: 1
103+
) do
104+
nil ->
105+
[]
106+
107+
%Claim{group_id: group_id} ->
108+
Repo.update_all(
109+
from(c in Claim, where: c.group_id == ^group_id),
110+
set: [status: :approved]
111+
)
112+
113+
Repo.all(
114+
from c in Claim,
115+
join: t in assoc(c, :target),
116+
join: tr in assoc(t, :repository),
117+
join: tru in assoc(tr, :user),
118+
join: u in assoc(c, :user),
119+
where: c.group_id == ^group_id,
120+
order_by: [desc: c.group_share, asc: c.inserted_at],
121+
select_merge: %{
122+
target: %{t | repository: %{tr | user: tru}},
123+
user: u
124+
}
125+
)
126+
end
127+
128+
if claims == [] do
129+
:ok
130+
else
131+
primary_claim = List.first(claims)
132+
133+
installation =
134+
Repo.one(
135+
from i in Installation,
136+
where: i.provider == "github",
137+
where: i.provider_id == ^to_string(installation["id"])
138+
)
139+
140+
bounties =
141+
Repo.all(
142+
from(b in Bounty,
143+
join: t in assoc(b, :ticket),
144+
join: o in assoc(b, :owner),
145+
left_join: u in assoc(b, :creator),
146+
left_join: c in assoc(o, :customer),
147+
left_join: p in assoc(c, :default_payment_method),
148+
where: t.id == ^primary_claim.target_id,
149+
select_merge: %{owner: %{o | customer: %{default_payment_method: p}}, creator: u}
150+
)
151+
)
152+
153+
autopayable_bounty =
154+
Enum.find(
155+
bounties,
156+
&(not is_nil(installation) and
157+
&1.owner.id == installation.connected_user_id and
158+
not is_nil(&1.owner.customer) and
159+
not is_nil(&1.owner.customer.default_payment_method))
160+
)
161+
162+
autopay_result =
163+
if autopayable_bounty do
164+
with {:ok, invoice} <-
165+
Bounties.create_invoice(
166+
%{
167+
owner: autopayable_bounty.owner,
168+
amount: autopayable_bounty.amount
169+
},
170+
ticket_ref: %{
171+
owner: repository["owner"]["login"],
172+
repo: repository["name"],
173+
number: pull_request["number"]
174+
},
175+
bounty_id: autopayable_bounty.id,
176+
claims: claims
177+
),
178+
{:ok, _invoice} <-
179+
Algora.Stripe.pay_invoice(invoice, %{
180+
payment_method: autopayable_bounty.owner.customer.default_payment_method.provider_id,
181+
off_session: true
182+
}) do
183+
Logger.info("Autopay successful (#{autopayable_bounty.owner.name} - #{autopayable_bounty.amount}).")
184+
:ok
185+
else
186+
{:error, reason} ->
187+
Logger.error(
188+
"Autopay failed (#{autopayable_bounty.owner.name} - #{autopayable_bounty.amount}): #{inspect(reason)}"
189+
)
190+
191+
:error
192+
end
193+
end
194+
195+
unpaid_bounties =
196+
Enum.filter(
197+
bounties,
198+
&case autopay_result do
199+
:ok -> &1.id != autopayable_bounty.id
200+
_ -> true
201+
end
202+
)
203+
204+
sponsors_to_notify =
205+
unpaid_bounties
206+
|> Enum.map(&(&1.creator || &1.owner))
207+
|> Enum.map_join(", ", &"@#{&1.provider_login}")
208+
209+
if unpaid_bounties != [] do
210+
names =
211+
claims
212+
|> Enum.map(fn c -> "@#{c.user.provider_login}" end)
213+
|> Algora.Util.format_name_list()
214+
215+
Github.create_issue_comment(
216+
token,
217+
primary_claim.target.repository.user.provider_login,
218+
primary_claim.target.repository.name,
219+
primary_claim.target.number,
220+
"🎉 The pull request of #{names} has been merged. You can visit [Algora](#{Claim.reward_url(primary_claim)}) to award the bounty." <>
221+
if(sponsors_to_notify == "", do: "", else: "\n\ncc #{sponsors_to_notify}")
222+
)
223+
end
224+
225+
:ok
226+
end
227+
end
228+
end
229+
230+
def process_event(_event_action, _params) do
231+
:ok
232+
end
233+
66234
defp execute_command(event_action, {:bounty, args}, author, params)
67235
when event_action in ["issues.opened", "issues.edited", "issue_comment.created", "issue_comment.edited"] do
68236
[event, _action] = String.split(event_action, ".")
@@ -242,12 +410,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do
242410
end
243411
end
244412

245-
def process_commands(%Webhook{event: event, hook_id: hook_id}, params) do
246-
author = get_author(event, params)
247-
body = get_body(event, params)
248-
249-
event_action = event <> "." <> params["action"]
250-
413+
def process_commands(%Webhook{hook_id: hook_id}, event_action, author, body, params) do
251414
case build_commands(body) do
252415
{:ok, commands} ->
253416
Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} ->
@@ -270,17 +433,20 @@ defmodule AlgoraWeb.Webhooks.GithubController do
270433
end
271434
end
272435

273-
defp get_author("issues", params), do: params["issue"]["user"]
274-
defp get_author("issue_comment", params), do: params["comment"]["user"]
275-
defp get_author("pull_request", params), do: params["pull_request"]["user"]
276-
defp get_author("pull_request_review", params), do: params["review"]["user"]
277-
defp get_author("pull_request_review_comment", params), do: params["comment"]["user"]
278-
defp get_author(_event, _params), do: nil
279-
280-
defp get_body("issues", params), do: params["issue"]["body"]
281-
defp get_body("issue_comment", params), do: params["comment"]["body"]
282-
defp get_body("pull_request", params), do: params["pull_request"]["body"]
283-
defp get_body("pull_request_review", params), do: params["review"]["body"]
284-
defp get_body("pull_request_review_comment", params), do: params["comment"]["body"]
285-
defp get_body(_event, _params), do: nil
436+
def join_event_action(event, params), do: event <> "." <> params["action"]
437+
438+
def split_event_action(event_action) do
439+
[event, action] = String.split(event_action, ".")
440+
{event, action}
441+
end
442+
443+
def get_entity_key("issues"), do: "issue"
444+
def get_entity_key("issue_comment"), do: "comment"
445+
def get_entity_key("pull_request"), do: "pull_request"
446+
def get_entity_key("pull_request_review"), do: "review"
447+
def get_entity_key("pull_request_review_comment"), do: "comment"
448+
def get_entity_key(_event), do: nil
449+
450+
def get_author(event, params), do: get_in(params, ["#{get_entity_key(event)}", "user"])
451+
def get_body(event, params), do: get_in(params, ["#{get_entity_key(event)}", "body"])
286452
end

0 commit comments

Comments
 (0)