Skip to content

Commit 2783ea2

Browse files
committed
feat: use idempotency keys to allow retries without making duplicate transfers
1 parent 0d98536 commit 2783ea2

File tree

3 files changed

+55
-77
lines changed

3 files changed

+55
-77
lines changed

lib/algora/payments/jobs/execute_pending_transfers.ex

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ defmodule Algora.Payments.Jobs.ExecutePendingTransfer do
22
@moduledoc false
33
use Oban.Worker,
44
queue: :transfers,
5-
max_attempts: 1,
65
unique: [period: :infinity]
76

87
alias Algora.Payments

lib/algora/payments/payments.ex

Lines changed: 49 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -328,23 +328,21 @@ defmodule Algora.Payments do
328328
{:ok, PSP.transfer()} | {:error, :not_found} | {:error, :duplicate_transfer_attempt}
329329
def execute_pending_transfer(credit_id) do
330330
with {:ok, credit} <- Repo.fetch_by(Transaction, id: credit_id, type: :credit, status: :succeeded) do
331-
transfers =
332-
Repo.all(
333-
from(t in Transaction,
334-
where: t.user_id == ^credit.user_id,
335-
where: t.group_id == ^credit.group_id,
336-
where: t.type == :transfer,
337-
where: t.status in [:initialized, :processing, :succeeded]
338-
)
339-
)
340-
341-
amount_transferred = Enum.reduce(transfers, Money.zero(:USD), fn t, acc -> Money.add!(acc, t.net_amount) end)
342-
343-
if Money.positive?(amount_transferred) do
344-
Logger.error("Duplicate transfer attempt at transaction #{credit_id}")
345-
{:error, :duplicate_transfer_attempt}
346-
else
347-
initialize_and_execute_transfer(credit)
331+
case fetch_active_account(credit.user_id) do
332+
{:ok, account} ->
333+
with {:ok, transaction} <- fetch_or_create_transfer(credit),
334+
{:ok, transfer} <- execute_transfer(transaction, account) do
335+
broadcast()
336+
{:ok, transfer}
337+
else
338+
error ->
339+
Logger.error("Failed to execute transfer: #{inspect(error)}")
340+
error
341+
end
342+
343+
_ ->
344+
Logger.error("Attempted to execute transfer to inactive account")
345+
{:error, :no_active_account}
348346
end
349347
end
350348
end
@@ -395,53 +393,43 @@ defmodule Algora.Payments do
395393
end
396394
end
397395

398-
@spec initialize_and_execute_transfer(credit :: Transaction.t()) :: {:ok, PSP.transfer()} | {:error, term()}
399-
defp initialize_and_execute_transfer(%Transaction{} = credit) do
400-
case fetch_active_account(credit.user_id) do
401-
{:ok, account} ->
402-
with {:ok, transaction} <- initialize_transfer(credit),
403-
{:ok, transfer} <- execute_transfer(transaction, account) do
404-
broadcast()
405-
{:ok, transfer}
406-
else
407-
error ->
408-
Logger.error("Failed to execute transfer: #{inspect(error)}")
409-
error
410-
end
396+
def fetch_or_create_transfer(%Transaction{} = credit) do
397+
idempotency_key = "credit_#{credit.id}"
411398

412-
_ ->
413-
Logger.error("Attempted to execute transfer to inactive account")
414-
{:error, :no_active_account}
399+
case Repo.get_by(Transaction, idempotency_key: idempotency_key) do
400+
nil ->
401+
%Transaction{}
402+
|> change(%{
403+
id: Nanoid.generate(),
404+
provider: "stripe",
405+
type: :transfer,
406+
status: :initialized,
407+
tip_id: credit.tip_id,
408+
bounty_id: credit.bounty_id,
409+
contract_id: credit.contract_id,
410+
claim_id: credit.claim_id,
411+
user_id: credit.user_id,
412+
gross_amount: credit.net_amount,
413+
net_amount: credit.net_amount,
414+
total_fee: Money.zero(:USD),
415+
group_id: credit.group_id,
416+
idempotency_key: idempotency_key
417+
})
418+
|> Algora.Validations.validate_positive(:gross_amount)
419+
|> Algora.Validations.validate_positive(:net_amount)
420+
|> unique_constraint(:idempotency_key)
421+
|> foreign_key_constraint(:user_id)
422+
|> foreign_key_constraint(:tip_id)
423+
|> foreign_key_constraint(:bounty_id)
424+
|> foreign_key_constraint(:contract_id)
425+
|> foreign_key_constraint(:claim_id)
426+
|> Repo.insert()
427+
428+
transfer ->
429+
{:ok, transfer}
415430
end
416431
end
417432

418-
def initialize_transfer(%Transaction{} = credit) do
419-
%Transaction{}
420-
|> change(%{
421-
id: Nanoid.generate(),
422-
provider: "stripe",
423-
type: :transfer,
424-
status: :initialized,
425-
tip_id: credit.tip_id,
426-
bounty_id: credit.bounty_id,
427-
contract_id: credit.contract_id,
428-
claim_id: credit.claim_id,
429-
user_id: credit.user_id,
430-
gross_amount: credit.net_amount,
431-
net_amount: credit.net_amount,
432-
total_fee: Money.zero(:USD),
433-
group_id: credit.group_id
434-
})
435-
|> Algora.Validations.validate_positive(:gross_amount)
436-
|> Algora.Validations.validate_positive(:net_amount)
437-
|> foreign_key_constraint(:user_id)
438-
|> foreign_key_constraint(:tip_id)
439-
|> foreign_key_constraint(:bounty_id)
440-
|> foreign_key_constraint(:contract_id)
441-
|> foreign_key_constraint(:claim_id)
442-
|> Repo.insert()
443-
end
444-
445433
def execute_transfer(%Transaction{} = transaction, account) do
446434
charge = Repo.get_by(Transaction, type: :charge, status: :succeeded, group_id: transaction.group_id)
447435

@@ -455,7 +443,7 @@ defmodule Algora.Payments do
455443
|> Map.merge(if transaction.group_id, do: %{transfer_group: transaction.group_id}, else: %{})
456444
|> Map.merge(if charge && charge.provider_id, do: %{source_transaction: charge.provider_id}, else: %{})
457445

458-
case PSP.Transfer.create(transfer_params, %{idempotency_key: transaction.id}) do
446+
case PSP.Transfer.create(transfer_params, %{idempotency_key: transaction.idempotency_key}) do
459447
{:ok, transfer} ->
460448
transaction
461449
|> change(%{

test/algora/payments_test.exs

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,11 @@ defmodule Algora.PaymentsTest do
127127
assert transfer_tx.user_id == credit_tx.user_id
128128
assert transfer_tx.provider_id == transfer.id
129129

130-
{result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit_tx.id) end)
131-
assert {:error, :duplicate_transfer_attempt} = result
132-
133-
transfer_tx |> change(status: :succeeded) |> Repo.update!()
134-
{result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit_tx.id) end)
135-
assert {:error, :duplicate_transfer_attempt} = result
130+
{:ok, transfer2} = Payments.execute_pending_transfer(credit_tx.id)
131+
{:ok, transfer_tx2} = Repo.fetch_by(Transaction, type: :transfer, provider_id: transfer2.id)
136132

137-
transfer_tx |> change(status: :initialized) |> Repo.update!()
138-
{result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit_tx.id) end)
139-
assert {:error, :duplicate_transfer_attempt} = result
140-
141-
transfer_tx |> change(status: :processing) |> Repo.update!()
142-
{result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit_tx.id) end)
143-
assert {:error, :duplicate_transfer_attempt} = result
133+
assert transfer2.id == transfer.id
134+
assert transfer_tx2.id == transfer_tx.id
144135
end
145136

146137
test "allows retrying failed/canceled transfers", %{user: user, account: account} do
@@ -264,8 +255,7 @@ defmodule Algora.PaymentsTest do
264255
group_id: Nanoid.generate()
265256
)
266257

267-
{:ok, transfer_tx} = Payments.initialize_transfer(credit_tx)
268-
258+
{:ok, transfer_tx} = Payments.fetch_or_create_transfer(credit_tx)
269259
{:ok, transfer1} = Payments.execute_transfer(transfer_tx, account)
270260
{:ok, transfer_tx1} = Repo.fetch_by(Transaction, type: :transfer)
271261

@@ -276,6 +266,7 @@ defmodule Algora.PaymentsTest do
276266
assert transfer_tx1.user_id == credit_tx.user_id
277267
assert transfer_tx1.provider_id == transfer1.id
278268

269+
{:ok, transfer_tx} = Payments.fetch_or_create_transfer(credit_tx)
279270
{:ok, transfer2} = Payments.execute_transfer(transfer_tx, account)
280271
{:ok, transfer_tx2} = Repo.fetch_by(Transaction, type: :transfer)
281272

0 commit comments

Comments
 (0)