Skip to content

Commit 1da7e3a

Browse files
authored
make invoice payments idempotent (#57)
1 parent 60264b4 commit 1da7e3a

File tree

9 files changed

+208
-58
lines changed

9 files changed

+208
-58
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,8 @@ defmodule Algora.Bounties do
616616
net_amount: amount,
617617
total_fee: Money.sub!(gross_amount, amount),
618618
line_items: line_items,
619-
group_id: tx_group_id
619+
group_id: tx_group_id,
620+
idempotency_key: "session-#{Nanoid.generate()}"
620621
}),
621622
{:ok, _transactions} <-
622623
create_transaction_pairs(%{
@@ -641,7 +642,7 @@ defmodule Algora.Bounties do
641642
end
642643

643644
@spec create_invoice(
644-
%{owner: User.t(), amount: Money.t()},
645+
%{owner: User.t(), amount: Money.t(), idempotency_key: String.t()},
645646
opts :: [
646647
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
647648
tip_id: String.t(),
@@ -651,7 +652,7 @@ defmodule Algora.Bounties do
651652
]
652653
) ::
653654
{:ok, PSP.invoice()} | {:error, atom()}
654-
def create_invoice(%{owner: owner, amount: amount}, opts \\ []) do
655+
def create_invoice(%{owner: owner, amount: amount, idempotency_key: idempotency_key}, opts \\ []) do
655656
tx_group_id = Nanoid.generate()
656657

657658
line_items =
@@ -672,7 +673,8 @@ defmodule Algora.Bounties do
672673
net_amount: amount,
673674
total_fee: Money.sub!(gross_amount, amount),
674675
line_items: line_items,
675-
group_id: tx_group_id
676+
group_id: tx_group_id,
677+
idempotency_key: idempotency_key
676678
}),
677679
{:ok, _transactions} <-
678680
create_transaction_pairs(%{
@@ -686,15 +688,19 @@ defmodule Algora.Bounties do
686688
}),
687689
{:ok, customer} <- Payments.fetch_or_create_customer(owner),
688690
{:ok, invoice} <-
689-
PSP.Invoice.create(%{
690-
auto_advance: false,
691-
customer: customer.provider_id
692-
}),
691+
PSP.Invoice.create(
692+
%{
693+
auto_advance: false,
694+
customer: customer.provider_id
695+
},
696+
%{idempotency_key: idempotency_key}
697+
),
693698
{:ok, _line_items} <-
694699
line_items
695700
|> Enum.map(&LineItem.to_invoice_item(&1, invoice, customer))
696-
|> Enum.reduce_while({:ok, []}, fn params, {:ok, acc} ->
697-
case PSP.Invoiceitem.create(params) do
701+
|> Enum.with_index()
702+
|> Enum.reduce_while({:ok, []}, fn {params, index}, {:ok, acc} ->
703+
case PSP.Invoiceitem.create(params, %{idempotency_key: "#{idempotency_key}-#{index}"}) do
698704
{:ok, item} -> {:cont, {:ok, [item | acc]}}
699705
{:error, error} -> {:halt, {:error, error}}
700706
end
@@ -711,7 +717,8 @@ defmodule Algora.Bounties do
711717
net_amount: net_amount,
712718
total_fee: total_fee,
713719
line_items: line_items,
714-
group_id: group_id
720+
group_id: group_id,
721+
idempotency_key: idempotency_key
715722
}) do
716723
%Transaction{}
717724
|> change(%{
@@ -724,12 +731,14 @@ defmodule Algora.Bounties do
724731
net_amount: net_amount,
725732
total_fee: total_fee,
726733
line_items: Util.normalize_struct(line_items),
727-
group_id: group_id
734+
group_id: group_id,
735+
idempotency_key: idempotency_key
728736
})
729737
|> Algora.Validations.validate_positive(:gross_amount)
730738
|> Algora.Validations.validate_positive(:net_amount)
731739
|> Algora.Validations.validate_positive(:total_fee)
732740
|> foreign_key_constraint(:user_id)
741+
|> unique_constraint([:idempotency_key])
733742
|> Repo.insert()
734743
end
735744

lib/algora/contracts/contracts.ex

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ defmodule Algora.Contracts do
395395
defp maybe_generate_invoice(contract, charge) do
396396
invoice_params = %{auto_advance: false, customer: contract.client.customer.provider_id}
397397

398-
with {:ok, invoice} <- Invoice.create(invoice_params),
398+
with {:ok, invoice} <- Invoice.create(invoice_params, %{idempotency_key: "contract-#{contract.id}"}),
399399
{:ok, _line_items} <- create_line_items(contract, invoice, charge.line_items) do
400400
{:ok, invoice}
401401
end
@@ -441,14 +441,19 @@ defmodule Algora.Contracts do
441441
end
442442

443443
defp create_line_items(contract, invoice, line_items) do
444-
Enum.reduce_while(line_items, {:ok, []}, fn line_item, {:ok, acc} ->
445-
case Algora.PSP.Invoiceitem.create(%{
446-
invoice: invoice.id,
447-
customer: contract.client.customer.provider_id,
448-
amount: MoneyUtils.to_minor_units(line_item.amount),
449-
currency: to_string(line_item.amount.currency),
450-
description: line_item.description
451-
}) do
444+
line_items
445+
|> Enum.with_index()
446+
|> Enum.reduce_while({:ok, []}, fn {line_item, index}, {:ok, acc} ->
447+
case Algora.PSP.Invoiceitem.create(
448+
%{
449+
invoice: invoice.id,
450+
customer: contract.client.customer.provider_id,
451+
amount: MoneyUtils.to_minor_units(line_item.amount),
452+
currency: to_string(line_item.amount.currency),
453+
description: line_item.description
454+
},
455+
%{idempotency_key: "contract-#{contract.id}-#{index}"}
456+
) do
452457
{:ok, item} -> {:cont, {:ok, [item | acc]}}
453458
{:error, error} -> {:halt, {:error, error}}
454459
end
@@ -460,7 +465,9 @@ defmodule Algora.Contracts do
460465
defp maybe_pay_invoice(contract, invoice, txs) do
461466
pm_id = contract.client.customer.default_payment_method.provider_id
462467

463-
case Invoice.pay(invoice.id, %{off_session: true, payment_method: pm_id}) do
468+
case Invoice.pay(invoice.id, %{off_session: true, payment_method: pm_id}, %{
469+
idempotency_key: "contract-#{contract.id}"
470+
}) do
464471
{:ok, stripe_invoice} ->
465472
if stripe_invoice.paid, do: release_funds(contract, stripe_invoice, txs)
466473
{:ok, stripe_invoice}

lib/algora/payments/schemas/transaction.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ defmodule Algora.Payments.Transaction do
3131
field :succeeded_at, :utc_datetime_usec
3232
field :reversed_at, :utc_datetime_usec
3333
field :group_id, :string
34+
field :idempotency_key, :string
3435

3536
belongs_to :timesheet, Algora.Contracts.Timesheet
3637
belongs_to :contract, Contract
@@ -63,7 +64,8 @@ defmodule Algora.Payments.Transaction do
6364
:contract_id,
6465
:original_contract_id,
6566
:user_id,
66-
:succeeded_at
67+
:succeeded_at,
68+
:idempotency_key
6769
])
6870
|> validate_required([
6971
:id,
@@ -79,6 +81,7 @@ defmodule Algora.Payments.Transaction do
7981
:original_contract_id,
8082
:user_id
8183
])
84+
|> unique_constraint([:idempotency_key])
8285
|> foreign_key_constraint(:user_id)
8386
|> foreign_key_constraint(:contract_id)
8487
|> foreign_key_constraint(:original_contract_id)

lib/algora/psp/psp.ex

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,75 @@ defmodule Algora.PSP do
2626

2727
@type error :: Stripe.Error.t()
2828

29-
@type invoice :: Stripe.Invoice.t()
29+
@type metadata :: %{
30+
optional(String.t()) => String.t()
31+
}
32+
33+
@type invoice :: Algora.PSP.Invoice.t()
3034
defmodule Invoice do
3135
@moduledoc false
36+
@type t :: Stripe.Invoice.t()
37+
38+
@spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()}
39+
when params:
40+
%{
41+
optional(:auto_advance) => boolean,
42+
:customer => Stripe.id() | Stripe.Customer.t()
43+
}
44+
| %{},
45+
options: %{
46+
:idempotency_key => String.t()
47+
}
48+
def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts))
49+
50+
@spec pay(Stripe.id() | t, params, options) :: {:ok, t} | {:error, Algora.PSP.error()}
51+
when params:
52+
%{
53+
optional(:off_session) => boolean,
54+
optional(:payment_method) => String.t()
55+
}
56+
| %{},
57+
options: %{
58+
:idempotency_key => String.t()
59+
}
60+
def pay(invoice_id, params, opts), do: Algora.PSP.client(__MODULE__).pay(invoice_id, params, Keyword.new(opts))
3261

33-
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
34-
def pay(invoice_id, params), do: Algora.PSP.client(__MODULE__).pay(invoice_id, params)
3562
def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id)
36-
def retrieve(id, opts), do: Algora.PSP.client(__MODULE__).retrieve(id, opts)
63+
def retrieve(id, opts), do: Algora.PSP.client(__MODULE__).retrieve(id, Keyword.new(opts))
3764
end
3865

39-
@type invoiceitem :: Stripe.Invoiceitem.t()
66+
@type invoiceitem :: Algora.PSP.Invoiceitem.t()
4067
defmodule Invoiceitem do
4168
@moduledoc false
42-
43-
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
69+
@type t :: Stripe.Invoiceitem.t()
70+
71+
@spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()}
72+
when params:
73+
%{
74+
optional(:amount) => integer,
75+
:currency => String.t(),
76+
:customer => Stripe.id() | Stripe.Customer.t(),
77+
optional(:description) => String.t(),
78+
optional(:invoice) => Stripe.id() | Stripe.Invoice.t()
79+
}
80+
| %{},
81+
options: %{
82+
:idempotency_key => String.t()
83+
}
84+
def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts))
4485
end
4586

46-
@type transfer :: Stripe.Transfer.t()
87+
@type transfer :: Algora.PSP.Transfer.t()
4788
defmodule Transfer do
4889
@moduledoc false
90+
@type t :: Stripe.Transfer.t()
4991

50-
@spec create(params, options) :: {:ok, Algora.PSP.transfer()} | {:error, Algora.PSP.error()}
92+
@spec create(params, options) :: {:ok, t} | {:error, Algora.PSP.error()}
5193
when params: %{
5294
:amount => pos_integer,
5395
:currency => String.t(),
5496
:destination => String.t(),
55-
optional(:metadata) => Stripe.Types.metadata(),
97+
optional(:metadata) => Algora.PSP.metadata(),
5698
optional(:source_transaction) => String.t(),
5799
optional(:transfer_group) => String.t(),
58100
optional(:description) => String.t(),
@@ -64,73 +106,82 @@ defmodule Algora.PSP do
64106
def create(params, opts), do: Algora.PSP.client(__MODULE__).create(params, Keyword.new(opts))
65107
end
66108

67-
@type session :: Stripe.Session.t()
109+
@type session :: Algora.PSP.Session.t()
68110
defmodule Session do
69111
@moduledoc false
70112

113+
@type t :: Stripe.Session.t()
71114
@type line_item_data :: Stripe.Session.line_item_data()
72115
@type payment_intent_data :: Stripe.Session.payment_intent_data()
73116

74117
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
75118
end
76119

77-
@type payment_method :: Stripe.PaymentMethod.t()
120+
@type payment_method :: Algora.PSP.PaymentMethod.t()
78121
defmodule PaymentMethod do
79122
@moduledoc false
80123

124+
@type t :: Stripe.PaymentMethod.t()
81125
def attach(params), do: Algora.PSP.client(__MODULE__).attach(params)
82126
def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id)
83127
end
84128

85-
@type payment_intent :: Stripe.PaymentIntent.t()
129+
@type payment_intent :: Algora.PSP.PaymentIntent.t()
86130
defmodule PaymentIntent do
87131
@moduledoc false
88132

133+
@type t :: Stripe.PaymentIntent.t()
89134
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
90135
end
91136

92-
@type setup_intent :: Stripe.SetupIntent.t()
137+
@type setup_intent :: Algora.PSP.SetupIntent.t()
93138
defmodule SetupIntent do
94139
@moduledoc false
95140

141+
@type t :: Stripe.SetupIntent.t()
96142
def retrieve(id, params), do: Algora.PSP.client(__MODULE__).retrieve(id, params)
97143
end
98144

99-
@type customer :: Stripe.Customer.t()
145+
@type customer :: Algora.PSP.Customer.t()
100146
defmodule Customer do
101147
@moduledoc false
102148

149+
@type t :: Stripe.Customer.t()
103150
def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id)
104151
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
105152
end
106153

107-
@type account :: Stripe.Account.t()
154+
@type account :: Algora.PSP.Account.t()
108155
defmodule Account do
109156
@moduledoc false
110157

158+
@type t :: Stripe.Account.t()
111159
def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id)
112160
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
113161
def delete(id), do: Algora.PSP.client(__MODULE__).delete(id)
114162
end
115163

116-
@type account_link :: Stripe.AccountLink.t()
164+
@type account_link :: Algora.PSP.AccountLink.t()
117165
defmodule AccountLink do
118166
@moduledoc false
119167

168+
@type t :: Stripe.AccountLink.t()
120169
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
121170
end
122171

123-
@type login_link :: Stripe.LoginLink.t()
172+
@type login_link :: Algora.PSP.LoginLink.t()
124173
defmodule LoginLink do
125174
@moduledoc false
126175

176+
@type t :: Stripe.LoginLink.t()
127177
def create(params), do: Algora.PSP.client(__MODULE__).create(params)
128178
end
129179

130-
@type balance_transaction :: Stripe.BalanceTransaction.t()
180+
@type balance_transaction :: Algora.PSP.BalanceTransaction.t()
131181
defmodule BalanceTransaction do
132182
@moduledoc false
133183

184+
@type t :: Stripe.BalanceTransaction.t()
134185
def retrieve(id), do: Algora.PSP.client(__MODULE__).retrieve(id)
135186
end
136187
end

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,14 @@ defmodule AlgoraWeb.Webhooks.GithubController do
137137

138138
autopay_result =
139139
if autopayable_bounty do
140+
idempotency_key = "bounty-#{autopayable_bounty.id}"
141+
140142
with {:ok, invoice} <-
141143
Bounties.create_invoice(
142144
%{
143145
owner: autopayable_bounty.owner,
144-
amount: autopayable_bounty.amount
146+
amount: autopayable_bounty.amount,
147+
idempotency_key: idempotency_key
145148
},
146149
ticket_ref: %{
147150
owner: payload["repository"]["owner"]["login"],
@@ -152,10 +155,14 @@ defmodule AlgoraWeb.Webhooks.GithubController do
152155
claims: claims
153156
),
154157
{:ok, _invoice} <-
155-
Algora.PSP.Invoice.pay(invoice, %{
156-
payment_method: autopayable_bounty.owner.customer.default_payment_method.provider_id,
157-
off_session: true
158-
}) do
158+
Algora.PSP.Invoice.pay(
159+
invoice,
160+
%{
161+
payment_method: autopayable_bounty.owner.customer.default_payment_method.provider_id,
162+
off_session: true
163+
},
164+
%{idempotency_key: idempotency_key}
165+
) do
159166
Logger.info("Autopay successful (#{autopayable_bounty.owner.name} - #{autopayable_bounty.amount}).")
160167
:ok
161168
else
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
defmodule Algora.Repo.Migrations.AddIdempotencyKeyToTransaction do
2+
use Ecto.Migration
3+
4+
def change do
5+
alter table(:transactions) do
6+
add :idempotency_key, :string
7+
end
8+
9+
create unique_index(:transactions, [:idempotency_key])
10+
end
11+
end

0 commit comments

Comments
 (0)