wallets 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +23 -1
- data/lib/generators/wallets/templates/add_wallets_idempotency_keys.rb.erb +47 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +6 -0
- data/lib/generators/wallets/upgrade_generator.rb +39 -7
- data/lib/wallets/models/hold.rb +6 -2
- data/lib/wallets/models/wallet.rb +166 -39
- data/lib/wallets/version.rb +1 -1
- data/lib/wallets.rb +2 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8c10aa620f150ee7dc1a6da70a740d509a65d50616d6c4e382eb7abec1b15b6
|
|
4
|
+
data.tar.gz: e83766be64655b05bd0cf2a61b370bafbb15294ee8334d810b4a48bcc01136ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08cb2c92d219c9f1df67bb7536b3a5bb5e259489a48a882fe3ac58a95b264ddf5818a06493da4c4074df1c00799171ea46c93825be414a662fd95d1bb8562a21'
|
|
7
|
+
data.tar.gz: 2e567d9f49aef131682cfb4d1bc47d4fb96a8bfcc9c2e5f337549baf19a57e3b820300bf4105083139751f248900a50f9dbd0c018bbeadc76340b997e2cdb517
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## [0.5.0] - 2026-10-25
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Idempotency keys.** `credit`, `debit`, `transfer_to`, `hold`, `reserve` and `Hold#capture!` take `idempotency_key:`. The first call stores the key on the row it writes (the transaction, the transfer's outbound leg, the hold, or the capture's debit). A retry with the same key returns that row and posts nothing: no second posting, no callbacks, and no `InvalidHold` for a reservation the first call already captured. The same key for a different amount, category or target raises the new `Wallets::IdempotencyConflict` and writes nothing. Keys are unique per wallet among its transactions and among its holds, so a reservation and its capture can share one. The lookup runs under the wallet lock (a current read when a MySQL snapshot is stale), so concurrent duplicates post once, and a unique index on `(wallet_id, idempotency_key)` backs it up. `release!` needs no key: it was already idempotent. Asked for by ReplyBrain, whose at-least-once jobs reserve an estimate and capture the actual cost.
|
|
6
|
+
|
|
7
|
+
### Upgrading
|
|
8
|
+
|
|
9
|
+
- Run `rails generate wallets:upgrade` and `rails db:migrate`. The generator now writes `add_wallets_idempotency_keys`, which adds a nullable `idempotency_key` string and a unique `(wallet_id, idempotency_key)` index to the transactions and holds tables; it is a no-op where they exist, and fresh installs get them from `wallets:install`. Until it has run, a command given a key raises `Wallets::Error`; commands without a key work exactly as before. Embedded ledgers opt in by adding the same column and index to their own tables.
|
|
10
|
+
|
|
1
11
|
## [0.4.3] - 2026-10-19
|
|
2
12
|
|
|
3
13
|
### Fixed
|
data/README.md
CHANGED
|
@@ -293,6 +293,27 @@ user.wallet(:coins).credit(
|
|
|
293
293
|
|
|
294
294
|
Debits consume the soonest-expiring credits first, then the oldest. Expired value is never spent, and neither is held value (see [Holds and reservations](#holds-and-reservations)).
|
|
295
295
|
|
|
296
|
+
### Retries and idempotency keys
|
|
297
|
+
|
|
298
|
+
Webhooks arrive twice and jobs run again. Give a command an `idempotency_key:` and it posts once, however many times it runs:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
wallet.credit(5_000, category: :top_up, idempotency_key: "stripe:#{invoice.id}")
|
|
302
|
+
wallet.credit(5_000, category: :top_up, idempotency_key: "stripe:#{invoice.id}") # => the same transaction, nothing added
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`credit`, `debit`, `transfer_to`, `hold`, `reserve` and `capture!` all take one. The first call stores the key on the row it writes. A retry with the same key returns that row: no second posting, no callbacks, not even an `InvalidHold` for a reservation the first call already captured. That makes the reserve-then-capture pattern safe in an at-least-once job:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
reservation = wallet.reserve(estimate, category: :run, idempotency_key: "run:#{run.id}")
|
|
309
|
+
# ... do the work ...
|
|
310
|
+
reservation.capture!(actual, category: :run, idempotency_key: "run:#{run.id}")
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Keys are unique per wallet, once among its transactions and once among its holds, so a reservation and its capture can share one. A retry must ask for the same thing: the same amount, category and target (the recipient of a transfer, the credit a hold covers, the reservation a capture charges). The same key for anything else raises `Wallets::IdempotencyConflict` and writes nothing. Metadata, deadlines and expirations are the first call's, so a retry that recomputes `1.hour.from_now` is still a retry. A command that failed or rolled back stored no key, so its retry runs for real. `release!` needs no key: releasing a hold that already ended does nothing.
|
|
314
|
+
|
|
315
|
+
Duplicates that run at the same moment queue on the wallet's row lock, and the second one finds the first one's row. A unique index on `(wallet_id, idempotency_key)` backs that up in the database.
|
|
316
|
+
|
|
296
317
|
## Holds and reservations
|
|
297
318
|
|
|
298
319
|
Sometimes value is in a wallet but must not be spent yet. A reward waits for validation. A seller's earnings wait out the return window. A booking sets money aside until the service is delivered. A fraud review freezes one credit, or the whole wallet. `wallets` models all of these with **holds**, and reads them the way your bank statement does:
|
|
@@ -554,7 +575,7 @@ rails db:migrate
|
|
|
554
575
|
|
|
555
576
|
> **Migrate before the 0.4.0 code serves traffic.** The new code expects the holds table and the wallets' `ledger_revision` column. Until `rails db:migrate` has added them, reading a `Wallets::Wallet`'s balance, or running any command on it (an initial balance included), raises: a ledger that keeps holds fails loudly on purpose rather than treat held value as spendable. Deploy the migration first, then the code.
|
|
556
577
|
|
|
557
|
-
The upgrade generator writes
|
|
578
|
+
The upgrade generator writes up to two migrations. The first adds the holds table, and a `ledger_revision` column on the wallets table, to a 0.1, 0.2 or 0.3 install. The second adds the `idempotency_key` columns and their unique indexes (0.5) to any earlier install; until it has run, a command given a key raises instead of ignoring it, and commands without one work as before. It follows your `table_prefix`, and reads the key types from the tables you already have, uuid and unsigned keys included. Running it again never writes a duplicate, and it writes neither migration when the schema already has what it adds. Rolling the migration back removes them only while the holds table is empty. Fresh installs get both from `rails generate wallets:install`.
|
|
558
579
|
|
|
559
580
|
Then look at where your app reads `wallet.balance`. It still means "what can be spent", so spending checks need no change. Screens and reports that mean "everything the user has", such as a total earned, should read `total_balance`. A reconciliation of the cached `balance` column compares it with `wallet.balance`, never with `total_balance`: the column stores the available balance, so reserving 30 of 100 leaves it at 70 while `total_balance` reads 100. Even then the two can differ through time alone, as credits expire and deadlines pass.
|
|
560
581
|
|
|
@@ -595,6 +616,7 @@ end
|
|
|
595
616
|
- If your wallets can be destroyed, destroy their holds first. `Wallets::Wallet` does it in a `before_destroy` callback declared with `prepend: true`, so the holds go before the transactions they point at.
|
|
596
617
|
- If you override `current_balance`, decide on purpose how it treats held and reserved value. With holds on, `balance`, `held_balance` and `total_balance` come from one set of terms. Overriding `current_balance` alone can break `total_balance == balance + held_balance`, so keep all three coherent.
|
|
597
618
|
- `credit` now takes the keywords `hold:` and `available_at:`, and `debit` takes `from:`. If your transaction table has columns by those names, you can no longer pass them through `credit` or `debit` as extra attributes.
|
|
619
|
+
- To accept idempotency keys, add a `string :idempotency_key` column and a unique index on `(wallet_id, idempotency_key)` to your transactions table, and to your holds table if you keep holds. A ledger without the column raises when a command is given a key, and works as before without one.
|
|
598
620
|
|
|
599
621
|
### Performance and concurrency
|
|
600
622
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Adds idempotency keys (wallets 0.5) to an existing installation: a key on
|
|
4
|
+
# transactions and on holds, unique per wallet, so a retried command returns
|
|
5
|
+
# what it posted the first time instead of posting again.
|
|
6
|
+
class AddWalletsIdempotencyKeys < ActiveRecord::Migration<%= migration_version %>
|
|
7
|
+
def up
|
|
8
|
+
[transactions_table, holds_table].each do |table|
|
|
9
|
+
next unless table_exists?(table)
|
|
10
|
+
|
|
11
|
+
add_column table, :idempotency_key, :string unless column_exists?(table, :idempotency_key)
|
|
12
|
+
unless index_exists?(table, [:wallet_id, :idempotency_key], unique: true)
|
|
13
|
+
add_index table, [:wallet_id, :idempotency_key], unique: true, name: index_name(table)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The keys only recognise retries: dropping them loses no value, only the
|
|
19
|
+
# ability to tell a retry from a new command.
|
|
20
|
+
def down
|
|
21
|
+
[transactions_table, holds_table].each do |table|
|
|
22
|
+
next unless table_exists?(table) && column_exists?(table, :idempotency_key)
|
|
23
|
+
|
|
24
|
+
remove_index table, name: index_name(table) if index_name_exists?(table, index_name(table))
|
|
25
|
+
remove_column table, :idempotency_key
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def table_prefix
|
|
32
|
+
Wallets.configuration.table_prefix
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def transactions_table
|
|
36
|
+
"#{table_prefix}transactions"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def holds_table
|
|
40
|
+
"#{table_prefix}holds"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The names the install template gives the same indexes.
|
|
44
|
+
def index_name(table)
|
|
45
|
+
"index_#{table}_on_wallet_and_idempotency_key"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -37,6 +37,8 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
37
|
t.datetime :expires_at
|
|
38
38
|
t.references :transfer, type: foreign_key_type, foreign_key: { to_table: transfers_table }
|
|
39
39
|
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
40
|
+
# The key a retried command is recognised by, unique per wallet.
|
|
41
|
+
t.string :idempotency_key
|
|
40
42
|
|
|
41
43
|
t.timestamps
|
|
42
44
|
end
|
|
@@ -82,6 +84,8 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
82
84
|
t.string :release_reason
|
|
83
85
|
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
84
86
|
t.send(json_column_type, :release_metadata, null: false, default: json_column_default)
|
|
87
|
+
# The key a retried hold or reservation is recognised by, unique per wallet.
|
|
88
|
+
t.string :idempotency_key
|
|
85
89
|
|
|
86
90
|
t.timestamps
|
|
87
91
|
|
|
@@ -92,6 +96,7 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
92
96
|
t.index [:wallet_id, :released_at], name: index_name("holds_on_wallet_and_released_at")
|
|
93
97
|
t.index [:transaction_id, :released_at], name: index_name("holds_on_tx_and_released_at")
|
|
94
98
|
t.index :releases_at, name: index_name("holds_on_releases_at")
|
|
99
|
+
t.index [:wallet_id, :idempotency_key], unique: true, name: index_name("holds_on_wallet_and_idempotency_key")
|
|
95
100
|
|
|
96
101
|
t.check_constraint "amount IS NULL OR amount > 0", name: constraint_name("holds_amount_positive")
|
|
97
102
|
# A credit hold holds the credit's whole remaining value, never an amount.
|
|
@@ -118,6 +123,7 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
118
123
|
add_index transactions_table, :category
|
|
119
124
|
add_index transactions_table, :expires_at
|
|
120
125
|
add_index transactions_table, [:wallet_id, :amount], name: index_name("transactions_on_wallet_id_and_amount")
|
|
126
|
+
add_index transactions_table, [:wallet_id, :idempotency_key], unique: true, name: index_name("transactions_on_wallet_and_idempotency_key")
|
|
121
127
|
add_index transactions_table, [:expires_at, :id], name: index_name("transactions_on_expires_at_and_id")
|
|
122
128
|
add_index allocations_table, [:transaction_id, :source_transaction_id], name: index_name("allocations_on_tx_and_source_tx")
|
|
123
129
|
add_index transfers_table, [:from_wallet_id, :to_wallet_id, :asset_code], name: index_name("transfers_on_wallets_and_asset")
|
|
@@ -5,8 +5,9 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module Wallets
|
|
7
7
|
module Generators
|
|
8
|
-
# Brings an existing installation's schema up to date
|
|
9
|
-
#
|
|
8
|
+
# Brings an existing installation's schema up to date: the holds table
|
|
9
|
+
# that 0.4 adds to 0.1, 0.2 and 0.3 installs, and the idempotency keys
|
|
10
|
+
# that 0.5 adds to every earlier install.
|
|
10
11
|
class UpgradeGenerator < Rails::Generators::Base
|
|
11
12
|
include ActiveRecord::Generators::Migration
|
|
12
13
|
|
|
@@ -26,25 +27,42 @@ module Wallets
|
|
|
26
27
|
existing_migration = self.class.migration_exists?(File.expand_path(db_migrate_path, destination_root), "add_wallets_holds")
|
|
27
28
|
|
|
28
29
|
if existing_migration
|
|
30
|
+
@pending = true
|
|
29
31
|
say_status :exist, relative_to_original_destination_root(existing_migration), :blue
|
|
30
32
|
elsif upgraded_schema?
|
|
31
|
-
@skipped = true
|
|
32
33
|
say_status :skip, "#{holds_table} already exists: there is nothing to migrate", :yellow
|
|
33
34
|
else
|
|
35
|
+
@pending = true
|
|
34
36
|
migration_template "add_wallets_holds.rb.erb", File.join(db_migrate_path, "add_wallets_holds.rb")
|
|
35
37
|
end
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Idempotent in the same way: written once, and only for a schema
|
|
41
|
+
# whose transactions do not have their key yet.
|
|
42
|
+
def create_idempotency_keys_migration
|
|
43
|
+
existing_migration = self.class.migration_exists?(File.expand_path(db_migrate_path, destination_root), "add_wallets_idempotency_keys")
|
|
44
|
+
|
|
45
|
+
if existing_migration
|
|
46
|
+
@pending = true
|
|
47
|
+
say_status :exist, relative_to_original_destination_root(existing_migration), :blue
|
|
48
|
+
elsif keyed_schema?
|
|
49
|
+
say_status :skip, "#{transactions_table} already has idempotency keys: there is nothing to migrate", :yellow
|
|
50
|
+
else
|
|
51
|
+
@pending = true
|
|
52
|
+
migration_template "add_wallets_idempotency_keys.rb.erb", File.join(db_migrate_path, "add_wallets_idempotency_keys.rb")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
38
56
|
def display_post_upgrade_message
|
|
39
|
-
|
|
40
|
-
say "\n✅ This app already has the `wallets`
|
|
57
|
+
unless @pending
|
|
58
|
+
say "\n✅ This app already has the latest `wallets` schema: nothing to migrate.", :green
|
|
41
59
|
say "\nRead the 0.4.0 CHANGELOG: `balance` is now the AVAILABLE balance, and held value is excluded from it."
|
|
42
60
|
return
|
|
43
61
|
end
|
|
44
62
|
|
|
45
|
-
say "\n🎉 The `wallets` upgrade
|
|
63
|
+
say "\n🎉 The `wallets` upgrade migrations are ready.", :green
|
|
46
64
|
say "\nTo complete the upgrade:"
|
|
47
|
-
say " 1. Run 'rails db:migrate' to add the holds table (a no-op if
|
|
65
|
+
say " 1. Run 'rails db:migrate' to add the holds table and the idempotency keys (each a no-op if already there)."
|
|
48
66
|
say " 2. Read the 0.4.0 CHANGELOG: `balance` is now the AVAILABLE balance, and held value is excluded from it."
|
|
49
67
|
end
|
|
50
68
|
|
|
@@ -60,10 +78,24 @@ module Wallets
|
|
|
60
78
|
false
|
|
61
79
|
end
|
|
62
80
|
|
|
81
|
+
# Same rule: a database that cannot be asked gets the migration.
|
|
82
|
+
def keyed_schema?
|
|
83
|
+
ActiveRecord::Base.with_connection do |connection|
|
|
84
|
+
connection.column_exists?(transactions_table, :idempotency_key) &&
|
|
85
|
+
(!connection.table_exists?(holds_table) || connection.column_exists?(holds_table, :idempotency_key))
|
|
86
|
+
end
|
|
87
|
+
rescue ActiveRecord::ActiveRecordError
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
63
91
|
def holds_table
|
|
64
92
|
"#{Wallets.configuration.table_prefix}holds"
|
|
65
93
|
end
|
|
66
94
|
|
|
95
|
+
def transactions_table
|
|
96
|
+
"#{Wallets.configuration.table_prefix}transactions"
|
|
97
|
+
end
|
|
98
|
+
|
|
67
99
|
def wallets_table
|
|
68
100
|
"#{Wallets.configuration.table_prefix}wallets"
|
|
69
101
|
end
|
data/lib/wallets/models/hold.rb
CHANGED
|
@@ -162,12 +162,16 @@ module Wallets
|
|
|
162
162
|
# funded at the moment it happens: value a hold covers is not spent, and
|
|
163
163
|
# unless overdrafts are on a capture the wallet cannot fund raises
|
|
164
164
|
# Wallets::InsufficientBalance and leaves the reservation as it was.
|
|
165
|
-
|
|
165
|
+
#
|
|
166
|
+
# With `idempotency_key:` a retried capture returns the debit it posted
|
|
167
|
+
# the first time instead of raising, so an at-least-once job can simply
|
|
168
|
+
# run again (see `Wallets::WalletBase#credit`).
|
|
169
|
+
def capture!(amount = nil, category:, metadata: {}, idempotency_key: nil)
|
|
166
170
|
raise InvalidHold, "Only a saved reservation can be captured" unless persisted?
|
|
167
171
|
|
|
168
172
|
# The wallet runs the capture: it takes its own lock before reading
|
|
169
173
|
# this reservation, the same order as every other command.
|
|
170
|
-
wallet.send(:capture_reservation!, self, amount, category: category, metadata: metadata)
|
|
174
|
+
wallet.send(:capture_reservation!, self, amount, category: category, metadata: metadata, idempotency_key: idempotency_key)
|
|
171
175
|
end
|
|
172
176
|
|
|
173
177
|
private
|
|
@@ -37,6 +37,7 @@ module Wallets
|
|
|
37
37
|
CORE_TRANSACTION_ATTRIBUTE_NAMES = %i[
|
|
38
38
|
id wallet wallet_id amount category expires_at metadata transfer
|
|
39
39
|
transfer_id balance_before balance_after created_at updated_at
|
|
40
|
+
idempotency_key
|
|
40
41
|
].freeze
|
|
41
42
|
|
|
42
43
|
# Plain SELECTs on these adapters may answer from a stale snapshot inside
|
|
@@ -381,29 +382,42 @@ module Wallets
|
|
|
381
382
|
# wallet.credit(751, category: :reward, hold: :pending_validation)
|
|
382
383
|
# wallet.credit(751, category: :reward, hold: {category: :pending_validation, until: 3.months.from_now, metadata: {ride_id: 7}})
|
|
383
384
|
# wallet.credit(500, category: :reward, available_at: 14.days.from_now) # held as :pending until then
|
|
384
|
-
|
|
385
|
+
#
|
|
386
|
+
# Every command that posts takes an `idempotency_key:`. The first call
|
|
387
|
+
# with a key posts and stores the key on the row it writes; a retry with
|
|
388
|
+
# the same key returns that row and posts nothing, so a job that runs
|
|
389
|
+
# twice charges once. The same key for a different command raises
|
|
390
|
+
# Wallets::IdempotencyConflict.
|
|
391
|
+
#
|
|
392
|
+
# wallet.credit(500, category: :top_up, idempotency_key: "stripe:#{invoice.id}")
|
|
393
|
+
def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, hold: nil, available_at: nil, idempotency_key: nil, **extra_transaction_attributes)
|
|
385
394
|
metadata = normalize_metadata(metadata)
|
|
386
395
|
extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
|
|
387
396
|
arrival_hold = arrival_hold_attributes(hold, available_at)
|
|
397
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, transaction_class)
|
|
388
398
|
|
|
389
399
|
# A savepoint even inside a caller's transaction, like every command: a
|
|
390
400
|
# credit that cannot get its hold must not survive as a spendable credit
|
|
391
401
|
# because the caller rescued the error and committed.
|
|
392
402
|
with_lock(requires_new: true) do
|
|
393
403
|
mutating_at(Time.current) do
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
same_credit = ->(earlier) { earlier.amount == normalize_positive_amount!(amount) && earlier.category == category.to_s && earlier.transfer_id == transfer&.id }
|
|
405
|
+
|
|
406
|
+
once_per_key(idempotency_key, transaction_class, same: same_credit) do
|
|
407
|
+
# Value that becomes available at an instant already reached is
|
|
408
|
+
# simply available: there is nothing left to hold.
|
|
409
|
+
arrival_hold = nil if available_at && arrival_hold[:releases_at] <= evaluation_time
|
|
410
|
+
|
|
411
|
+
apply_credit(
|
|
412
|
+
amount,
|
|
413
|
+
metadata: metadata,
|
|
414
|
+
category: category,
|
|
415
|
+
expires_at: expires_at,
|
|
416
|
+
transfer: transfer,
|
|
417
|
+
extra_attributes: extra_transaction_attributes.merge(keyed(idempotency_key)),
|
|
418
|
+
hold: arrival_hold
|
|
419
|
+
)
|
|
420
|
+
end
|
|
407
421
|
end
|
|
408
422
|
end
|
|
409
423
|
end
|
|
@@ -426,20 +440,27 @@ module Wallets
|
|
|
426
440
|
# with overdrafts off, what it takes from unheld credits must fit in the
|
|
427
441
|
# available balance, so naming an unheld credit cannot spend value that a
|
|
428
442
|
# reservation set aside, nor deepen a deficit.
|
|
429
|
-
def debit(amount, metadata: {}, category: :debit, transfer: nil, from: ELIGIBLE_CREDITS, **extra_transaction_attributes)
|
|
443
|
+
def debit(amount, metadata: {}, category: :debit, transfer: nil, from: ELIGIBLE_CREDITS, idempotency_key: nil, **extra_transaction_attributes)
|
|
430
444
|
metadata = normalize_metadata(metadata)
|
|
431
445
|
extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
|
|
432
446
|
sources = named_credits(from)
|
|
447
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, transaction_class)
|
|
433
448
|
|
|
434
449
|
with_lock(requires_new: true) do
|
|
435
|
-
|
|
436
|
-
amount
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
450
|
+
mutating_at(Time.current) do
|
|
451
|
+
same_debit = ->(earlier) { earlier.amount == -normalize_positive_amount!(amount) && earlier.category == category.to_s && earlier.transfer_id == transfer&.id && !capture?(earlier) }
|
|
452
|
+
|
|
453
|
+
once_per_key(idempotency_key, transaction_class, same: same_debit) do
|
|
454
|
+
apply_debit(
|
|
455
|
+
amount,
|
|
456
|
+
metadata: metadata,
|
|
457
|
+
category: category,
|
|
458
|
+
transfer: transfer,
|
|
459
|
+
extra_attributes: extra_transaction_attributes.merge(keyed(idempotency_key)),
|
|
460
|
+
from: sources
|
|
461
|
+
)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
443
464
|
end
|
|
444
465
|
end
|
|
445
466
|
|
|
@@ -458,7 +479,7 @@ module Wallets
|
|
|
458
479
|
#
|
|
459
480
|
# Only leaving the credit out holds the whole wallet: an explicit nil
|
|
460
481
|
# (a credit that was looked up and not found) is refused.
|
|
461
|
-
def hold(credit = WHOLE_WALLET, category:, until: nil, metadata: {})
|
|
482
|
+
def hold(credit = WHOLE_WALLET, category:, until: nil, metadata: {}, idempotency_key: nil)
|
|
462
483
|
ensure_holds_enabled!
|
|
463
484
|
raise ArgumentError, "Pass no credit to hold the whole wallet; nil is not a credit" if credit.nil?
|
|
464
485
|
|
|
@@ -467,15 +488,20 @@ module Wallets
|
|
|
467
488
|
# `until` is a Ruby keyword: the argument reads as plain English at the
|
|
468
489
|
# call site, and the binding is the only way to reach it in here.
|
|
469
490
|
releases_at = normalize_deadline(binding.local_variable_get(:until))
|
|
491
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, hold_class)
|
|
470
492
|
|
|
471
493
|
with_lock(requires_new: true) do
|
|
472
494
|
mutating_at(Time.current) do
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
495
|
+
same_hold = ->(earlier) { earlier.coverage? && earlier.transaction_id == credit&.id && earlier.category == category.to_s.strip }
|
|
496
|
+
|
|
497
|
+
once_per_key(idempotency_key, hold_class, same: same_hold) do
|
|
498
|
+
# The target first: a wrong argument is a wrong argument, whatever
|
|
499
|
+
# the deadline says.
|
|
500
|
+
ensure_holdable!(credit) unless credit.nil?
|
|
501
|
+
ensure_deadline_ahead!(releases_at)
|
|
477
502
|
|
|
478
|
-
|
|
503
|
+
place_hold!(credit: credit, category: category, releases_at: releases_at, metadata: metadata, **keyed(idempotency_key))
|
|
504
|
+
end
|
|
479
505
|
end
|
|
480
506
|
end
|
|
481
507
|
end
|
|
@@ -485,22 +511,33 @@ module Wallets
|
|
|
485
511
|
# must fit in `balance` unless `allow_negative_balance` is on.
|
|
486
512
|
#
|
|
487
513
|
# wallet.reserve(3_000, category: :booking, until: 7.days.from_now, metadata: {booking_id: 9})
|
|
488
|
-
|
|
514
|
+
#
|
|
515
|
+
# A reservation made from a job that may run twice takes a key, and so
|
|
516
|
+
# does its capture (see `credit`):
|
|
517
|
+
#
|
|
518
|
+
# reservation = wallet.reserve(estimate, category: :run, idempotency_key: "run:#{run.id}")
|
|
519
|
+
# reservation.capture!(actual, category: :run, idempotency_key: "run:#{run.id}")
|
|
520
|
+
def reserve(amount, category:, until: nil, metadata: {}, idempotency_key: nil)
|
|
489
521
|
ensure_holds_enabled!
|
|
490
522
|
amount = normalize_positive_amount!(amount)
|
|
491
523
|
metadata = normalize_metadata(metadata)
|
|
492
524
|
releases_at = normalize_deadline(binding.local_variable_get(:until))
|
|
525
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, hold_class)
|
|
493
526
|
|
|
494
527
|
with_lock(requires_new: true) do
|
|
495
528
|
mutating_at(Time.current) do
|
|
496
|
-
|
|
497
|
-
available = balance
|
|
529
|
+
same_reservation = ->(earlier) { earlier.amount == amount && earlier.category == category.to_s.strip }
|
|
498
530
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
531
|
+
once_per_key(idempotency_key, hold_class, same: same_reservation) do
|
|
532
|
+
ensure_deadline_ahead!(releases_at)
|
|
533
|
+
available = balance
|
|
534
|
+
|
|
535
|
+
if amount > available && !allow_negative_balance?
|
|
536
|
+
reject_insufficient_balance!(amount, available, metadata)
|
|
537
|
+
end
|
|
502
538
|
|
|
503
|
-
|
|
539
|
+
place_hold!(amount: amount, category: category, releases_at: releases_at, metadata: metadata, previous_balance: available, **keyed(idempotency_key))
|
|
540
|
+
end
|
|
504
541
|
end
|
|
505
542
|
end
|
|
506
543
|
end
|
|
@@ -514,7 +551,11 @@ module Wallets
|
|
|
514
551
|
# held value included, and the inbound legs are credited as usual.
|
|
515
552
|
#
|
|
516
553
|
# wallet.transfer_to(platform.wallet, 751, category: :reversal, from: credit)
|
|
517
|
-
|
|
554
|
+
#
|
|
555
|
+
# With `idempotency_key:` a retried transfer returns the Transfer it made
|
|
556
|
+
# the first time (see `credit`). The key is the sender's: it is stored
|
|
557
|
+
# on the outbound leg.
|
|
558
|
+
def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil, from: ELIGIBLE_CREDITS, idempotency_key: nil)
|
|
518
559
|
raise InvalidTransfer, "Target wallet is required" if other_wallet.nil?
|
|
519
560
|
raise InvalidTransfer, "Source wallet must be persisted" unless persisted?
|
|
520
561
|
raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted?
|
|
@@ -528,6 +569,7 @@ module Wallets
|
|
|
528
569
|
metadata = normalize_metadata(metadata)
|
|
529
570
|
resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at)
|
|
530
571
|
sources = named_credits(from)
|
|
572
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, transaction_class)
|
|
531
573
|
|
|
532
574
|
# A savepoint even inside a caller's transaction, like every command: a
|
|
533
575
|
# transfer whose inbound leg fails must take its outbound debit and its
|
|
@@ -538,6 +580,14 @@ module Wallets
|
|
|
538
580
|
|
|
539
581
|
# The recipient pins the same instant in its own `apply_credit`.
|
|
540
582
|
mutating_at(now) do
|
|
583
|
+
same_transfer = ->(earlier) {
|
|
584
|
+
sent = earlier.transfer
|
|
585
|
+
earlier.category == "transfer_out" && sent.present? &&
|
|
586
|
+
sent.to_wallet_id == other_wallet.id && sent.amount == amount && sent.category == category.to_s
|
|
587
|
+
}
|
|
588
|
+
earlier = earlier_command(idempotency_key, transaction_class, same: same_transfer)
|
|
589
|
+
next earlier.transfer if earlier
|
|
590
|
+
|
|
541
591
|
previous_balance = balance
|
|
542
592
|
|
|
543
593
|
# Insufficient-balance gate.
|
|
@@ -620,6 +670,7 @@ module Wallets
|
|
|
620
670
|
|
|
621
671
|
_outbound_transaction, allocations = apply_debit_with_allocations(
|
|
622
672
|
amount,
|
|
673
|
+
extra_attributes: keyed(idempotency_key),
|
|
623
674
|
category: :transfer_out,
|
|
624
675
|
metadata: shared_metadata.merge(
|
|
625
676
|
"counterparty_wallet_id" => other_wallet.id,
|
|
@@ -1270,14 +1321,27 @@ module Wallets
|
|
|
1270
1321
|
# the callbacks report are the ones before and after the whole step,
|
|
1271
1322
|
# never the instant in between where the reservation is gone and the
|
|
1272
1323
|
# debit not yet posted: capturing a fully reserved wallet reads 0 -> 0.
|
|
1273
|
-
|
|
1324
|
+
#
|
|
1325
|
+
# A capture with a key looks for it before anything else, so that the
|
|
1326
|
+
# retry of a capture that went through returns its debit rather than
|
|
1327
|
+
# raising Wallets::InvalidHold for the reservation it already ended.
|
|
1328
|
+
def capture_reservation!(reservation, amount, category:, metadata:, idempotency_key: nil)
|
|
1274
1329
|
metadata = normalize_metadata(metadata)
|
|
1330
|
+
idempotency_key = normalize_idempotency_key!(idempotency_key, transaction_class)
|
|
1275
1331
|
|
|
1276
1332
|
ending_hold(reservation) do
|
|
1277
1333
|
mutating_at(Time.current) do
|
|
1278
1334
|
# A locking read: the reservation as it is now, whatever an earlier
|
|
1279
1335
|
# read in this transaction saw.
|
|
1280
1336
|
reservation.lock!
|
|
1337
|
+
|
|
1338
|
+
same_capture = ->(earlier) {
|
|
1339
|
+
earlier.id == reservation.capture_transaction_id && earlier.category == category.to_s &&
|
|
1340
|
+
(amount.nil? || earlier.amount == -normalize_positive_amount!(amount))
|
|
1341
|
+
}
|
|
1342
|
+
earlier = earlier_command(idempotency_key, transaction_class, same: same_capture)
|
|
1343
|
+
next earlier if earlier
|
|
1344
|
+
|
|
1281
1345
|
ensure_capturable!(reservation)
|
|
1282
1346
|
amount = capture_amount(reservation, amount)
|
|
1283
1347
|
|
|
@@ -1293,7 +1357,7 @@ module Wallets
|
|
|
1293
1357
|
# With overdrafts on, a capture beyond the eligible credits leaves
|
|
1294
1358
|
# exactly the rest unbacked; one within them leaves no debt at all,
|
|
1295
1359
|
# however underfunded the other reservations end up.
|
|
1296
|
-
debit, _allocations = post_debit!(amount, metadata: metadata, category: category, transfer: nil, allow_unbacked: allow_negative_balance?)
|
|
1360
|
+
debit, _allocations = post_debit!(amount, metadata: metadata, category: category, transfer: nil, extra_attributes: keyed(idempotency_key), allow_unbacked: allow_negative_balance?)
|
|
1297
1361
|
reservation.update!(released_at: evaluation_time, release_reason: "captured", capture_transaction: debit)
|
|
1298
1362
|
new_balance = refresh_cached_balance!
|
|
1299
1363
|
|
|
@@ -1339,6 +1403,69 @@ module Wallets
|
|
|
1339
1403
|
end
|
|
1340
1404
|
end
|
|
1341
1405
|
|
|
1406
|
+
# =========================================
|
|
1407
|
+
# Idempotency Keys
|
|
1408
|
+
# =========================================
|
|
1409
|
+
|
|
1410
|
+
# The key a command was given, ready to look up and store: nil for none,
|
|
1411
|
+
# a non-blank string otherwise. A ledger whose table has no
|
|
1412
|
+
# `idempotency_key` column (an embedder's, or an install that has not
|
|
1413
|
+
# run `rails g wallets:upgrade`) refuses a key rather than ignore it: a
|
|
1414
|
+
# key that is silently dropped would let a retry post twice.
|
|
1415
|
+
def normalize_idempotency_key!(key, model)
|
|
1416
|
+
return if key.nil?
|
|
1417
|
+
|
|
1418
|
+
key = key.to_s
|
|
1419
|
+
raise ArgumentError, "idempotency_key can't be blank" if key.strip.empty?
|
|
1420
|
+
unless model.column_names.include?("idempotency_key")
|
|
1421
|
+
raise Error, "#{model.table_name} has no idempotency_key column: run `rails g wallets:upgrade` and migrate to use idempotency keys"
|
|
1422
|
+
end
|
|
1423
|
+
|
|
1424
|
+
key
|
|
1425
|
+
end
|
|
1426
|
+
|
|
1427
|
+
# The attributes that store a key on the row a command writes.
|
|
1428
|
+
def keyed(idempotency_key)
|
|
1429
|
+
idempotency_key.nil? ? {} : {idempotency_key: idempotency_key}
|
|
1430
|
+
end
|
|
1431
|
+
|
|
1432
|
+
# Runs a command once per key: the row an earlier call with this key
|
|
1433
|
+
# posted, or whatever the block posts now.
|
|
1434
|
+
def once_per_key(idempotency_key, model, same:)
|
|
1435
|
+
earlier_command(idempotency_key, model, same: same) || yield
|
|
1436
|
+
end
|
|
1437
|
+
|
|
1438
|
+
# The row this wallet already posted under `idempotency_key`, if any.
|
|
1439
|
+
# Read under the wallet lock at the command's instant, as a current read
|
|
1440
|
+
# when the snapshot is stale (see `stale_snapshot?`): every command on
|
|
1441
|
+
# this wallet queues on that lock, so a duplicate running at the same
|
|
1442
|
+
# time waits for the first to commit and then finds its row. The unique
|
|
1443
|
+
# index on (wallet_id, idempotency_key) is the backstop.
|
|
1444
|
+
#
|
|
1445
|
+
# `same` tells a retry from a different command that reuses the key: the
|
|
1446
|
+
# amount, the category and what the command acts on must match. Metadata,
|
|
1447
|
+
# deadlines and expirations are the first call's, so a retry that
|
|
1448
|
+
# recomputes `7.days.from_now` is still a retry.
|
|
1449
|
+
def earlier_command(idempotency_key, model, same:)
|
|
1450
|
+
return if idempotency_key.nil?
|
|
1451
|
+
|
|
1452
|
+
earlier = current_read(model.where(wallet_id: id, idempotency_key: idempotency_key), current_reads?).take
|
|
1453
|
+
return if earlier.nil?
|
|
1454
|
+
|
|
1455
|
+
unless same.call(earlier)
|
|
1456
|
+
raise IdempotencyConflict, "Idempotency key #{idempotency_key.inspect} was already used on wallet #{id} " \
|
|
1457
|
+
"for a different command (#{model.model_name.human.downcase} #{earlier.id})"
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1460
|
+
earlier
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# Whether a debit is a reservation's capture: a plain debit's key must
|
|
1464
|
+
# not replay a capture, nor the other way round.
|
|
1465
|
+
def capture?(debit)
|
|
1466
|
+
holds_enabled? && hold_class.where(capture_transaction_id: debit.id).exists?
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1342
1469
|
# A credit can be held when it is a saved credit of this very wallet and
|
|
1343
1470
|
# ledger, still unexpired at the command's instant, with value left to
|
|
1344
1471
|
# hold. Read from the database under the wallet lock, never from the
|
data/lib/wallets/version.rb
CHANGED
data/lib/wallets.rb
CHANGED
|
@@ -21,6 +21,8 @@ module Wallets
|
|
|
21
21
|
class InvalidTransfer < Error; end
|
|
22
22
|
class InvalidHold < Error; end
|
|
23
23
|
class HoldsNotEnabled < Error; end
|
|
24
|
+
# An idempotency key the wallet already used for a different command.
|
|
25
|
+
class IdempotencyConflict < Error; end
|
|
24
26
|
|
|
25
27
|
class << self
|
|
26
28
|
attr_writer :configuration
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wallets
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: rails
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- README.md
|
|
47
47
|
- lib/generators/wallets/install_generator.rb
|
|
48
48
|
- lib/generators/wallets/templates/add_wallets_holds.rb.erb
|
|
49
|
+
- lib/generators/wallets/templates/add_wallets_idempotency_keys.rb.erb
|
|
49
50
|
- lib/generators/wallets/templates/create_wallets_tables.rb.erb
|
|
50
51
|
- lib/generators/wallets/templates/initializer.rb
|
|
51
52
|
- lib/generators/wallets/upgrade_generator.rb
|