Skip to content

Commit 60264b4

Browse files
authored
make transfers idempotent (#56)
1 parent 76008eb commit 60264b4

File tree

5 files changed

+136
-22
lines changed

5 files changed

+136
-22
lines changed

lib/algora/contracts/contracts.ex

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -482,11 +482,14 @@ defmodule Algora.Contracts do
482482
defp transfer_funds(contract, %Transaction{type: :transfer} = transaction) when transaction.status != :succeeded do
483483
with {:ok, account} <- Repo.fetch_by(Account, user_id: transaction.user_id),
484484
{:ok, stripe_transfer} <-
485-
Algora.PSP.Transfer.create(%{
486-
amount: MoneyUtils.to_minor_units(transaction.net_amount),
487-
currency: to_string(transaction.net_amount.currency),
488-
destination: account.provider_id
489-
}) do
485+
Algora.PSP.Transfer.create(
486+
%{
487+
amount: MoneyUtils.to_minor_units(transaction.net_amount),
488+
currency: to_string(transaction.net_amount.currency),
489+
destination: account.provider_id
490+
},
491+
%{idempotency_key: transaction.id}
492+
) do
490493
update_transaction_status(transaction, stripe_transfer, :succeeded)
491494
mark_contract_as_paid(contract)
492495
{:ok, stripe_transfer}

lib/algora/payments/payments.ex

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ defmodule Algora.Payments do
411411
end
412412
end
413413

414-
defp initialize_transfer(%Transaction{} = credit) do
414+
def initialize_transfer(%Transaction{} = credit) do
415415
%Transaction{}
416416
|> change(%{
417417
id: Nanoid.generate(),
@@ -438,8 +438,8 @@ defmodule Algora.Payments do
438438
|> Repo.insert()
439439
end
440440

441-
defp execute_transfer(%Transaction{} = transaction, account) do
442-
charge = Repo.get_by(Transaction, type: :credit, status: :succeeded, group_id: transaction.group_id)
441+
def execute_transfer(%Transaction{} = transaction, account) do
442+
charge = Repo.get_by(Transaction, type: :charge, status: :succeeded, group_id: transaction.group_id)
443443

444444
transfer_params =
445445
%{
@@ -451,23 +451,21 @@ defmodule Algora.Payments do
451451
|> Map.merge(if transaction.group_id, do: %{transfer_group: transaction.group_id}, else: %{})
452452
|> Map.merge(if charge && charge.provider_id, do: %{source_transaction: charge.provider_id}, else: %{})
453453

454-
# TODO: provide idempotency key
455-
case PSP.Transfer.create(transfer_params) do
454+
case PSP.Transfer.create(transfer_params, %{idempotency_key: transaction.id}) do
456455
{:ok, transfer} ->
457-
# it's fine if this fails since we'll receive a webhook
458456
transaction
459457
|> change(%{
460458
status: :succeeded,
461459
succeeded_at: DateTime.utc_now(),
462460
provider_id: transfer.id,
461+
provider_transfer_id: transfer.id,
463462
provider_meta: Util.normalize_struct(transfer)
464463
})
465464
|> Repo.update()
466465

467466
{:ok, transfer}
468467

469468
{:error, error} ->
470-
# TODO: inconsistent state if this fails
471469
transaction
472470
|> change(%{status: :failed})
473471
|> Repo.update()

lib/algora/psp/psp.ex

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,21 @@ defmodule Algora.PSP do
4747
defmodule Transfer do
4848
@moduledoc false
4949

50-
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
50+
@spec create(params, options) :: {:ok, Algora.PSP.transfer()} | {:error, Algora.PSP.error()}
51+
when params: %{
52+
:amount => pos_integer,
53+
:currency => String.t(),
54+
:destination => String.t(),
55+
optional(:metadata) => Stripe.Types.metadata(),
56+
optional(:source_transaction) => String.t(),
57+
optional(:transfer_group) => String.t(),
58+
optional(:description) => String.t(),
59+
optional(:source_type) => String.t()
60+
},
61+
options: %{
62+
:idempotency_key => String.t()
63+
}
64+
def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts))
5165
end
5266

5367
@type session :: Stripe.Session.t()

test/algora/payments_test.exs

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ defmodule Algora.PaymentsTest do
1010
alias Algora.Payments.Transaction
1111
alias Algora.Repo
1212

13-
describe "execute_pending_transfer/1" do
14-
setup do
15-
user = insert(:user)
16-
account = insert(:account, user: user)
13+
setup do
14+
user = insert(:user)
15+
account = insert(:account, user: user)
1716

18-
{:ok, user: user, account: account}
19-
end
17+
{:ok, user: user, account: account}
18+
end
2019

20+
describe "execute_pending_transfer/1" do
2121
test "executes transfer when user has positive balance", %{user: user, account: account} do
2222
credit =
2323
insert(:transaction,
@@ -106,6 +106,68 @@ defmodule Algora.PaymentsTest do
106106
transfer_tx = Repo.one(from t in Transaction, where: t.type == :transfer)
107107
assert transfer_tx.status == :failed
108108
end
109+
110+
test "prevents duplicate transfers", %{user: user} do
111+
credit_tx =
112+
insert(:transaction,
113+
type: :credit,
114+
status: :succeeded,
115+
net_amount: Money.new(1, :USD),
116+
group_id: Nanoid.generate(),
117+
user: user
118+
)
119+
120+
{:ok, transfer} = Payments.execute_pending_transfer(credit_tx.id)
121+
{:ok, transfer_tx} = Repo.fetch_by(Transaction, type: :transfer, provider_id: transfer.id)
122+
123+
assert Money.equal?(transfer_tx.net_amount, Money.new(1, :USD))
124+
assert transfer_tx.status == :succeeded
125+
assert transfer_tx.succeeded_at != nil
126+
assert transfer_tx.group_id == credit_tx.group_id
127+
assert transfer_tx.user_id == credit_tx.user_id
128+
assert transfer_tx.provider_id == transfer.id
129+
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
136+
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
144+
end
145+
146+
test "allows retrying failed/canceled transfers", %{user: user, account: account} do
147+
credit_tx =
148+
insert(:transaction,
149+
type: :credit,
150+
status: :succeeded,
151+
net_amount: Money.new(1, :USD),
152+
group_id: Nanoid.generate(),
153+
user: user
154+
)
155+
156+
Account |> Repo.one!() |> change(%{provider_id: "acct_invalid"}) |> Repo.update!()
157+
{result, _log} = with_log(fn -> Payments.execute_pending_transfer(credit_tx.id) end)
158+
assert {:error, %Stripe.Error{code: :invalid_request_error}} = result
159+
160+
Account |> Repo.one!() |> change(%{provider_id: account.provider_id}) |> Repo.update!()
161+
{:ok, transfer} = Payments.execute_pending_transfer(credit_tx.id)
162+
{:ok, transfer_tx} = Repo.fetch_by(Transaction, type: :transfer, status: :succeeded)
163+
164+
assert Money.equal?(transfer_tx.net_amount, Money.new(1, :USD))
165+
assert transfer_tx.status == :succeeded
166+
assert transfer_tx.succeeded_at != nil
167+
assert transfer_tx.group_id == credit_tx.group_id
168+
assert transfer_tx.user_id == credit_tx.user_id
169+
assert transfer_tx.provider_id == transfer.id
170+
end
109171
end
110172

111173
describe "enqueue_pending_transfers/1" do
@@ -191,4 +253,41 @@ defmodule Algora.PaymentsTest do
191253
refute_enqueued(worker: ExecutePendingTransfer, args: %{credit_id: credit.id})
192254
end
193255
end
256+
257+
describe "execute_transfer/2" do
258+
test "executes transfer idempotently with same transfer ID and transaction ID", %{account: account} do
259+
credit_tx =
260+
insert(:transaction,
261+
type: :credit,
262+
status: :succeeded,
263+
net_amount: Money.new(1, :USD),
264+
group_id: Nanoid.generate()
265+
)
266+
267+
{:ok, transfer_tx} = Payments.initialize_transfer(credit_tx)
268+
269+
{:ok, transfer1} = Payments.execute_transfer(transfer_tx, account)
270+
{:ok, transfer_tx1} = Repo.fetch_by(Transaction, type: :transfer)
271+
272+
assert Money.equal?(transfer_tx1.net_amount, Money.new(1, :USD))
273+
assert transfer_tx1.status == :succeeded
274+
assert transfer_tx1.succeeded_at != nil
275+
assert transfer_tx1.group_id == credit_tx.group_id
276+
assert transfer_tx1.user_id == credit_tx.user_id
277+
assert transfer_tx1.provider_id == transfer1.id
278+
279+
{:ok, transfer2} = Payments.execute_transfer(transfer_tx, account)
280+
{:ok, transfer_tx2} = Repo.fetch_by(Transaction, type: :transfer)
281+
282+
assert Money.equal?(transfer_tx2.net_amount, Money.new(1, :USD))
283+
assert transfer_tx2.status == :succeeded
284+
assert transfer_tx2.succeeded_at != nil
285+
assert transfer_tx2.group_id == credit_tx.group_id
286+
assert transfer_tx2.user_id == credit_tx.user_id
287+
assert transfer_tx2.provider_id == transfer2.id
288+
289+
assert transfer1.id == transfer2.id
290+
assert transfer_tx1.id == transfer_tx2.id
291+
end
292+
end
194293
end

test/support/stripe_mock.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ defmodule Algora.Support.StripeMock do
3131
defmodule Transfer do
3232
@moduledoc false
3333

34-
def create(%{destination: "acct_invalid"}) do
34+
def create(%{destination: "acct_invalid"}, _opts) do
3535
{:error,
3636
%Stripe.Error{
3737
source: :stripe,
@@ -40,10 +40,10 @@ defmodule Algora.Support.StripeMock do
4040
}}
4141
end
4242

43-
def create(%{amount: amount, currency: currency, destination: destination}) do
43+
def create(%{amount: amount, currency: currency, destination: destination}, opts) do
4444
{:ok,
4545
%Stripe.Transfer{
46-
id: "tr_#{Algora.Util.random_int()}",
46+
id: "tr_#{:erlang.phash2(opts[:idempotency_key])}",
4747
amount: amount,
4848
currency: currency,
4949
destination: destination

0 commit comments

Comments
 (0)