Skip to content

Commit 5b53372

Browse files
authored
feat: contracts (#122)
1 parent 1035c99 commit 5b53372

33 files changed

+1342
-475
lines changed

lib/algora/accounts/accounts.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,15 @@ defmodule Algora.Accounts do
183183
where: tx.type == :credit,
184184
where: tx.status == :succeeded,
185185
where: tx.user_id == ^user.id,
186+
join: ltx in assoc(tx, :linked_transaction),
186187
left_join: bounty in assoc(tx, :bounty),
187188
left_join: tip in assoc(tx, :tip),
188189
join: t in Ticket,
189190
on: t.id == bounty.ticket_id or t.id == tip.ticket_id,
190191
left_join: r in assoc(t, :repository),
191192
as: :r,
192-
left_join: ro in assoc(r, :user),
193+
left_join: ro in User,
194+
on: fragment("? = (case when ? is null then ? else ? end)", ro.id, r.user_id, ltx.user_id, r.user_id),
193195
# order_by: ^[desc: order_by],
194196
order_by: [desc: sum(tx.net_amount)],
195197
group_by: [ro.id],

lib/algora/activities/schemas/activity.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Algora.Activities.Activity do
1010
contract_renewed
1111
identity_created
1212
user_migrated
13+
user_online
1314
bounty_posted
1415
bounty_repriced
1516
claim_submitted

lib/algora/admin/admin.ex

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule Algora.Admin do
1212
alias Algora.Github
1313
alias Algora.Parser
1414
alias Algora.Payments
15+
alias Algora.Payments.Transaction
1516
alias Algora.Repo
1617
alias Algora.Util
1718
alias Algora.Workspace
@@ -21,6 +22,48 @@ defmodule Algora.Admin do
2122

2223
require Logger
2324

25+
def release_payment(tx_id) do
26+
Repo.transact(fn ->
27+
{_, [tx]} =
28+
Repo.update_all(from(t in Transaction, where: t.id == ^tx_id, select: t),
29+
set: [status: :succeeded, succeeded_at: DateTime.utc_now()]
30+
)
31+
32+
Repo.update_all(from(b in Bounty, where: b.id == ^tx.bounty_id), set: [status: :paid])
33+
34+
activities_result = Repo.insert_activity(tx, %{type: :transaction_succeeded, notify_users: [tx.user_id]})
35+
36+
jobs_result =
37+
case Payments.fetch_active_account(tx.user_id) do
38+
{:ok, _account} ->
39+
%{credit_id: tx.id}
40+
|> Payments.Jobs.ExecutePendingTransfer.new()
41+
|> Oban.insert()
42+
43+
{:error, :no_active_account} ->
44+
Logger.warning("No active account for user #{tx.user_id}")
45+
46+
%{credit_id: tx.id}
47+
|> Bounties.Jobs.PromptPayoutConnect.new()
48+
|> Oban.insert()
49+
end
50+
51+
with {:ok, _} <- activities_result,
52+
{:ok, _} <- jobs_result do
53+
Payments.broadcast()
54+
{:ok, nil}
55+
else
56+
{:error, reason} ->
57+
Logger.error("Failed to update transactions: #{inspect(reason)}")
58+
{:error, :failed_to_update_transactions}
59+
60+
error ->
61+
Logger.error("Failed to update transactions: #{inspect(error)}")
62+
{:error, :failed_to_update_transactions}
63+
end
64+
end)
65+
end
66+
2467
def refresh_bounty(url) do
2568
with %{owner: owner, repo: repo, number: number} <- parse_ticket_url(url),
2669
{:ok, ticket} <- Workspace.ensure_ticket(token_for(owner), owner, repo, number) do
@@ -396,6 +439,8 @@ defmodule Algora.Admin do
396439
end
397440

398441
def alert(message, severity) do
442+
Logger.info(message)
443+
399444
%{
400445
payload: %{
401446
embeds: [

lib/algora/bounties/bounties.ex

Lines changed: 125 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ defmodule Algora.Bounties do
5252
ticket: Ticket.t(),
5353
visibility: Bounty.visibility(),
5454
shared_with: [String.t()],
55-
hours_per_week: integer() | nil
55+
hours_per_week: integer() | nil,
56+
hourly_rate: Money.t() | nil,
57+
contract_type: Bounty.contract_type() | nil
5658
}) ::
5759
{:ok, Bounty.t()} | {:error, atom()}
5860
defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do
@@ -64,7 +66,9 @@ defmodule Algora.Bounties do
6466
creator_id: creator.id,
6567
visibility: params[:visibility] || owner.bounty_mode,
6668
shared_with: params[:shared_with] || [],
67-
hours_per_week: params[:hours_per_week]
69+
hours_per_week: params[:hours_per_week],
70+
hourly_rate: params[:hourly_rate],
71+
contract_type: params[:contract_type]
6872
})
6973

7074
changeset
@@ -112,7 +116,9 @@ defmodule Algora.Bounties do
112116
command_source: :ticket | :comment,
113117
visibility: Bounty.visibility() | nil,
114118
shared_with: [String.t()] | nil,
115-
hours_per_week: integer() | nil
119+
hourly_rate: Money.t() | nil,
120+
hours_per_week: integer() | nil,
121+
contract_type: Bounty.contract_type() | nil
116122
]
117123
) ::
118124
{:ok, Bounty.t()} | {:error, atom()}
@@ -144,7 +150,9 @@ defmodule Algora.Bounties do
144150
ticket: ticket,
145151
visibility: opts[:visibility],
146152
shared_with: shared_with,
147-
hours_per_week: opts[:hours_per_week]
153+
hourly_rate: opts[:hourly_rate],
154+
hours_per_week: opts[:hours_per_week],
155+
contract_type: opts[:contract_type]
148156
})
149157

150158
:set ->
@@ -195,7 +203,9 @@ defmodule Algora.Bounties do
195203
strategy: strategy(),
196204
visibility: Bounty.visibility() | nil,
197205
shared_with: [String.t()] | nil,
198-
hours_per_week: integer() | nil
206+
hours_per_week: integer() | nil,
207+
hourly_rate: Money.t() | nil,
208+
contract_type: Bounty.contract_type() | nil
199209
]
200210
) ::
201211
{:ok, Bounty.t()} | {:error, atom()}
@@ -215,7 +225,9 @@ defmodule Algora.Bounties do
215225
ticket: ticket,
216226
visibility: opts[:visibility],
217227
shared_with: shared_with,
218-
hours_per_week: opts[:hours_per_week]
228+
hours_per_week: opts[:hours_per_week],
229+
hourly_rate: opts[:hourly_rate],
230+
contract_type: opts[:contract_type]
219231
}),
220232
{:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty}) do
221233
broadcast()
@@ -769,7 +781,12 @@ defmodule Algora.Bounties do
769781
bounty: Bounty.t(),
770782
claims: [Claim.t()]
771783
},
772-
opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, recipient: User.t()]
784+
opts :: [
785+
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
786+
recipient: User.t(),
787+
success_url: String.t(),
788+
cancel_url: String.t()
789+
]
773790
) ::
774791
{:ok, String.t()} | {:error, atom()}
775792
def reward_bounty(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do
@@ -778,7 +795,37 @@ defmodule Algora.Bounties do
778795
ticket_ref: opts[:ticket_ref],
779796
bounty: bounty,
780797
claims: claims,
781-
recipient: opts[:recipient]
798+
recipient: opts[:recipient],
799+
success_url: opts[:success_url],
800+
cancel_url: opts[:cancel_url]
801+
)
802+
end
803+
804+
@spec authorize_payment(
805+
%{
806+
owner: User.t(),
807+
amount: Money.t(),
808+
bounty: Bounty.t(),
809+
claims: [Claim.t()]
810+
},
811+
opts :: [
812+
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
813+
recipient: User.t(),
814+
success_url: String.t(),
815+
cancel_url: String.t()
816+
]
817+
) ::
818+
{:ok, String.t()} | {:error, atom()}
819+
def authorize_payment(%{owner: owner, amount: amount, bounty: bounty, claims: claims}, opts \\ []) do
820+
create_payment_session(
821+
%{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"},
822+
ticket_ref: opts[:ticket_ref],
823+
bounty: bounty,
824+
claims: claims,
825+
recipient: opts[:recipient],
826+
capture_method: :manual,
827+
success_url: opts[:success_url],
828+
cancel_url: opts[:cancel_url]
782829
)
783830
end
784831

@@ -801,61 +848,81 @@ defmodule Algora.Bounties do
801848
description = if(ticket_ref, do: "#{ticket_ref[:repo]}##{ticket_ref[:number]}")
802849

803850
platform_fee_pct =
804-
if bounty && Date.before?(bounty.inserted_at, ~D[2025-04-16]) do
851+
if bounty && Date.before?(bounty.inserted_at, ~D[2025-04-16]) && is_nil(bounty.contract_type) do
805852
Decimal.div(owner.fee_pct_prev, 100)
806853
else
807854
Decimal.div(owner.fee_pct, 100)
808855
end
809856

810857
transaction_fee_pct = Payments.get_transaction_fee_pct()
811858

812-
payouts =
813-
if recipient do
859+
case opts[:bounty] do
860+
%{contract_type: :marketplace} ->
814861
[
815862
%LineItem{
816863
amount: amount,
817-
title: "Payment to @#{recipient.provider_login}",
818-
description: description,
864+
title: "Contract payment - @#{recipient.provider_login}",
865+
description: "(includes all platform and payment processing fees)",
819866
image: recipient.avatar_url,
820867
type: :payout
821868
}
822869
]
823-
else
824-
Enum.map(claims, fn claim ->
825-
%LineItem{
826-
# TODO: ensure shares are normalized
827-
amount: Money.mult!(amount, claim.group_share),
828-
title: "Payment to @#{claim.user.provider_login}",
829-
description: description,
830-
image: claim.user.avatar_url,
831-
type: :payout
832-
}
833-
end)
834-
end
835870

836-
payouts ++
837-
[
838-
%LineItem{
839-
amount: Money.mult!(amount, platform_fee_pct),
840-
title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})",
841-
type: :fee
842-
},
843-
%LineItem{
844-
amount: Money.mult!(amount, transaction_fee_pct),
845-
title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})",
846-
type: :fee
847-
}
848-
]
871+
_ ->
872+
if recipient do
873+
[
874+
%LineItem{
875+
amount: amount,
876+
title: "Payment to @#{recipient.provider_login}",
877+
description: description,
878+
image: recipient.avatar_url,
879+
type: :payout
880+
}
881+
]
882+
else
883+
Enum.map(claims, fn claim ->
884+
%LineItem{
885+
# TODO: ensure shares are normalized
886+
amount: Money.mult!(amount, claim.group_share),
887+
title: "Payment to @#{claim.user.provider_login}",
888+
description: description,
889+
image: claim.user.avatar_url,
890+
type: :payout
891+
}
892+
end)
893+
end ++
894+
[
895+
%LineItem{
896+
amount: Money.mult!(amount, platform_fee_pct),
897+
title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})",
898+
type: :fee
899+
},
900+
%LineItem{
901+
amount: Money.mult!(amount, transaction_fee_pct),
902+
title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})",
903+
type: :fee
904+
}
905+
]
906+
end
849907
end
850908

909+
def calculate_contract_amount(amount), do: Money.mult!(amount, Decimal.new("1.13"))
910+
911+
def final_contract_amount(:marketplace, amount), do: amount
912+
913+
def final_contract_amount(:bring_your_own, amount), do: calculate_contract_amount(amount)
914+
851915
@spec create_payment_session(
852916
%{owner: User.t(), amount: Money.t(), description: String.t()},
853917
opts :: [
854918
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
855919
tip_id: String.t(),
856920
bounty: Bounty.t(),
857921
claims: [Claim.t()],
858-
recipient: User.t()
922+
recipient: User.t(),
923+
capture_method: :automatic | :automatic_async | :manual,
924+
success_url: String.t(),
925+
cancel_url: String.t()
859926
]
860927
) ::
861928
{:ok, String.t()} | {:error, atom()}
@@ -870,6 +937,19 @@ defmodule Algora.Bounties do
870937
bounty: opts[:bounty]
871938
)
872939

940+
payment_intent_data = %{
941+
description: description,
942+
metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id}
943+
}
944+
945+
{payment_intent_data, session_opts} =
946+
if capture_method = opts[:capture_method] do
947+
{Map.put(payment_intent_data, :capture_method, capture_method),
948+
[success_url: opts[:success_url], cancel_url: opts[:cancel_url]]}
949+
else
950+
{payment_intent_data, []}
951+
end
952+
873953
gross_amount = LineItem.gross_amount(line_items)
874954

875955
bounty_id = if bounty = opts[:bounty], do: bounty.id
@@ -899,10 +979,12 @@ defmodule Algora.Bounties do
899979
group_id: tx_group_id
900980
}),
901981
{:ok, session} <-
902-
Payments.create_stripe_session(owner, Enum.map(line_items, &LineItem.to_stripe/1), %{
903-
description: description,
904-
metadata: %{"version" => Payments.metadata_version(), "group_id" => tx_group_id}
905-
}) do
982+
Payments.create_stripe_session(
983+
owner,
984+
Enum.map(line_items, &LineItem.to_stripe/1),
985+
payment_intent_data,
986+
session_opts
987+
) do
906988
{:ok, session.url}
907989
end
908990
end)
@@ -1012,7 +1094,6 @@ defmodule Algora.Bounties do
10121094
})
10131095
|> Algora.Validations.validate_positive(:gross_amount)
10141096
|> Algora.Validations.validate_positive(:net_amount)
1015-
|> Algora.Validations.validate_positive(:total_fee)
10161097
|> foreign_key_constraint(:user_id)
10171098
|> unique_constraint([:idempotency_key])
10181099
|> Repo.insert()

0 commit comments

Comments
 (0)