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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30525c4168fc4fe526a19f8b53b07726accd6351867a03a3b1d91ca4b94d7355
4
- data.tar.gz: 8a6d11d3cdae5ddec055ac6b6f4a8028f7ab17a6913f1395813911b4243fea41
3
+ metadata.gz: b8c10aa620f150ee7dc1a6da70a740d509a65d50616d6c4e382eb7abec1b15b6
4
+ data.tar.gz: e83766be64655b05bd0cf2a61b370bafbb15294ee8334d810b4a48bcc01136ca
5
5
  SHA512:
6
- metadata.gz: 066b9709bc06269facf1f86026f2f20d8759753e42e6eb5cb052e22002a0ad4e163b416c81458ff24485ce79e9e90c7a17c7edc70b42bbdcf703c9ecfc501ae8
7
- data.tar.gz: b8b6aa97479b61fa96c150de76926ed388c29996de9e01eac84f1e0f5d6108fb3fa381c6f938dbb75c51432d70c42b6c513516369c5b176ddcb66fa6f23e41e0
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 one migration that adds the holds table, and a `ledger_revision` column on the wallets table, to a 0.1, 0.2 or 0.3 install. 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 second migration, and it writes none at all when both already exist. Rolling the migration back removes them only while the holds table is empty. Fresh installs get both from `rails generate wallets:install`.
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. Today that is one
9
- # migration: the holds table that 0.4 adds to 0.1, 0.2 and 0.3 installs.
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
- if @skipped
40
- say "\n✅ This app already has the `wallets` 0.4 holds table: nothing to migrate.", :green
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 migration is ready.", :green
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 it already exists)."
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
@@ -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
- def capture!(amount = nil, category:, metadata: {})
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
- def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, hold: nil, available_at: nil, **extra_transaction_attributes)
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
- # Value that becomes available at an instant already reached is
395
- # simply available: there is nothing left to hold.
396
- arrival_hold = nil if available_at && arrival_hold[:releases_at] <= evaluation_time
397
-
398
- apply_credit(
399
- amount,
400
- metadata: metadata,
401
- category: category,
402
- expires_at: expires_at,
403
- transfer: transfer,
404
- extra_attributes: extra_transaction_attributes,
405
- hold: arrival_hold
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
- apply_debit(
436
- amount,
437
- metadata: metadata,
438
- category: category,
439
- transfer: transfer,
440
- extra_attributes: extra_transaction_attributes,
441
- from: sources
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
- # The target first: a wrong argument is a wrong argument, whatever
474
- # the deadline says.
475
- ensure_holdable!(credit) unless credit.nil?
476
- ensure_deadline_ahead!(releases_at)
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
- place_hold!(credit: credit, category: category, releases_at: releases_at, metadata: metadata)
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
- def reserve(amount, category:, until: nil, metadata: {})
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
- ensure_deadline_ahead!(releases_at)
497
- available = balance
529
+ same_reservation = ->(earlier) { earlier.amount == amount && earlier.category == category.to_s.strip }
498
530
 
499
- if amount > available && !allow_negative_balance?
500
- reject_insufficient_balance!(amount, available, metadata)
501
- end
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
- place_hold!(amount: amount, category: category, releases_at: releases_at, metadata: metadata, previous_balance: available)
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
- def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil, from: ELIGIBLE_CREDITS)
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
- def capture_reservation!(reservation, amount, category:, metadata:)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wallets
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.0"
5
5
  end
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.3
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-18 00:00:00.000000000 Z
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