wallets 0.1.0 → 0.4.1
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 +129 -0
- data/README.md +803 -62
- data/lib/generators/wallets/install_generator.rb +1 -1
- data/lib/generators/wallets/templates/add_wallets_holds.rb.erb +150 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +72 -3
- data/lib/generators/wallets/templates/initializer.rb +52 -10
- data/lib/generators/wallets/upgrade_generator.rb +76 -0
- data/lib/wallets/callback_context.rb +1 -0
- data/lib/wallets/callback_dispatcher.rb +51 -0
- data/lib/wallets/callbacks.rb +9 -40
- data/lib/wallets/configuration.rb +19 -11
- data/lib/wallets/engine.rb +5 -9
- data/lib/wallets/models/allocation.rb +48 -16
- data/lib/wallets/models/concerns/embeddable.rb +36 -0
- data/lib/wallets/models/concerns/has_metadata.rb +73 -0
- data/lib/wallets/models/concerns/has_wallets.rb +25 -18
- data/lib/wallets/models/hold.rb +196 -0
- data/lib/wallets/models/transaction.rb +102 -54
- data/lib/wallets/models/transfer.rb +38 -46
- data/lib/wallets/models/wallet.rb +1445 -250
- data/lib/wallets/railtie.rb +6 -3
- data/lib/wallets/version.rb +1 -1
- data/lib/wallets/whole_number.rb +37 -0
- data/lib/wallets.rb +14 -2
- data/sig/wallets.rbs +98 -0
- metadata +17 -16
- data/.gitignore +0 -36
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -37
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -30
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06f03b26d96187aae331606ce523b158ad3303d3a0298cefdaf673d27f466bac
|
|
4
|
+
data.tar.gz: ecdb3cebac2c0b5ed8a83d14cf7a98e1b046ee59f3837bc2683bf3b2c0f8fe18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b8041ad7293df22cf7a159bcc76b4c8186a2019be95250da10b7d9fe1760dcc4f06dd517a0c454c2ab229eb433036ab34c548a895b9ba176936266103477844a
|
|
7
|
+
data.tar.gz: c258acf79ced27d6e6acc0311bea40dec74683cc27bd2bd1ab246b7dd111600419d80608677c622207df3b1c8ae58a796296ac6d27b4fe3b9043e9f4ec192f06
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
+
## [0.4.1] - 2026-10-18
|
|
2
|
+
|
|
3
|
+
### Documentation
|
|
4
|
+
|
|
5
|
+
- **Vesting.** A new "Vesting rewards" example: a referral bonus that unlocks in 14 days, a creator reward that vests 400 points a month, and a signup bonus the app releases when the member completes their first order, with `balance`, `held_balance` and `total_balance` read in between.
|
|
6
|
+
- **The bank-statement model.** A new "Bank-style accounts and fintech" example walks one day of an account (a salary that clears the next morning, a fuel pre-authorisation captured for less, a hotel hold that falls off) and reads the available and current balances after each step.
|
|
7
|
+
- **Retained credit.** A new "Retained rewards and reviews" example: a reward credited now and retained until a partner programme reimburses it, a review that retains one credit, several, or the whole account, how the app reads the reasons to explain them, and the two endings, `release!` or `from:`.
|
|
8
|
+
- **Escrow-style bookings and marketplaces.** The home-services example reserves the price at booking and captures the real cost at delivery, and a new "Marketplace seller earnings" example credits a sale held through the return window and cancels it with `from:` on a return.
|
|
9
|
+
- **Holds in the existing stories.** Loyalty points pending until the return window closes, gig fares that clear before the weekly payout, a tip retained during a dispute, and a tournament prize retained during an anti-cheat review. "Use it for", "Perfect use cases" and "Key signals" now include value that must exist before it may be spent.
|
|
10
|
+
- **Corrected: reconciling the cached column.** The upgrade notes said a reconciliation against the cached `balance` column should read `total_balance`. The column stores the available balance, so it is reconciled with `wallet.balance`, and the two can still differ through time alone, as credits expire and deadlines pass. `total_balance` is for displays of everything the user has.
|
|
11
|
+
- **Corrected: what the ledger records.** "Why this gem" said every balance change made through the wallet API creates a transaction row. Movements of value do. Holds, reservations and their releases change what is available through hold rows instead, so the available balance over time is rebuilt from both.
|
|
12
|
+
- Every new README sample runs as a test (`ReadmeUseCasesTest`) that asserts the values its comments promise, and `ReadmeHoldsTest` pins both corrections.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`capture!` checks the reservation's state before the amount**, as the README promised: retrying a finished capture (captured, released or lapsed) now raises `Wallets::InvalidHold` whatever amount it carries, so a retry handler that rescues `InvalidHold` is never bypassed by an `ArgumentError`. An invalid amount on an active reservation still raises `ArgumentError`; it is now refused inside the command's savepoint, after the locks, and nothing is written either way.
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
|
|
20
|
+
- The concurrency tests that did not race do now: the threaded wallet and transfer tests moved into the non-transactional harness with distinct real connections, the stubbed lock-order test became one that records the actual `FOR UPDATE` statements in both directions, and a new test races overdraft transfers from one wallet. The stale-read helper uses bounded waits, propagates worker errors and cleans up its threads and connections, and every racer holds its own connection before any of them starts, each spawned once the one before is ready; four harness tests prove a failing race fails fast and leaves nothing behind, whether the worker, the interleaved write, the block or a racer's connection fails, and a pool too small for a race fails it at once instead of hanging it. Both winner orders of a capture racing a release now run deterministically through the stale-read helper, as both orders of a hold racing a debit already did. The upgrade generator test edits the generated migration with a sentinel line, so every Rails version sees a file that differs from the template. 564 runs on SQLite, PostgreSQL and MySQL.
|
|
21
|
+
|
|
22
|
+
## [0.4.0] - 2026-10-17
|
|
23
|
+
|
|
24
|
+
Holds and reservations: value that is in the ledger but not spendable yet, read the way a bank statement reads it. `wallet.balance` is the available balance, `wallet.held_balance` is what is pending, and `wallet.total_balance` is both together. Existing installs run `rails generate wallets:upgrade && rails db:migrate`, then read **Changed**: `balance` now leaves held value out. **Run the migration before the 0.4.0 code serves traffic**: it adds the holds table and the wallets' `ledger_revision` column, and until both exist every balance read and every command on `Wallets::Wallet` raises. A ledger with holds enabled and no holds table fails loudly on purpose.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Holds on a credit.** `wallet.hold(credit, category:, until: nil, metadata: {})` keeps a credit's whole remaining value in the ledger but out of `balance` and out of every ordinary debit, until the hold is released or its `until:` deadline passes. Several holds on one credit are independent reasons: the credit stays held while any of them is active, and its value is counted once. Anything that is not a saved credit of this very wallet (another wallet's credit, a debit, an unsaved or deleted record, another ledger's class) is a wrong argument and raises `ArgumentError`; a credit of this wallet that is expired or has nothing left to hold, or a deadline already reached, raises `Wallets::InvalidHold`.
|
|
29
|
+
- **Holds on the whole wallet.** `wallet.hold(category:, ...)` with no credit holds every credit in the wallet, including credits that arrive later, and `wallet.on_hold?` reports it. An explicit `nil` credit raises `ArgumentError`, so a lookup that found nothing can never freeze a wallet.
|
|
30
|
+
- **Credits that arrive held.** `credit(amount, hold: :pending_validation)`, or `hold: { category:, until:, metadata: }`, writes the credit and its hold in one step, so no snapshot, cached balance or callback ever sees the value spendable. `credit(amount, available_at: time)` is shorthand for a `:pending` hold until `time`; a time already reached means no hold at all.
|
|
31
|
+
- **Reservations.** `wallet.reserve(amount, category:, until: nil, metadata: {})` sets an amount of the available balance aside, like a pending card charge, and reservations add up. A reservation is funded like a debit: the amount must fit in `balance` unless `allow_negative_balance` is on. Amounts are parsed exactly like a debit's, and an `until:` that is not a time raises `ArgumentError` instead of meaning "no deadline".
|
|
32
|
+
- **Capture.** `reservation.capture!(amount = reserved, category:, metadata: {})` posts the debit and ends the reservation in one step, releasing whatever was not captured. Only an active reservation can be captured, and only once: a released, lapsed or captured one raises `Wallets::InvalidHold` and creates nothing, so a retried request never charges twice. The state is checked before the amount, and capturing more than was reserved raises `ArgumentError`. The capture is funded when it happens, from unheld credits, with its own reservation counted back in. Its snapshots and callbacks see the balances before and after the whole step (a whole capture of a fully reserved wallet reads 0 before and 0 after). A hold's capture link must name one of its own wallet's debits, no larger than the reservation: the model validates it, next to the database's shape checks. A capture never lowers the available balance. It returns the debit, which carries the capture's metadata, and `reservation.capture_transaction` and `debit.captured_reservation` link the two.
|
|
33
|
+
- **Release.** `hold.release!(reason: nil, metadata: {})` ends a hold now and records `release_reason` and `release_metadata` next to the creation `metadata`, never over it. It is idempotent: a hold that has already ended (released, captured, or past its deadline) is returned unchanged and reports nothing. Deadlines are computed like expiry, never swept by a job.
|
|
34
|
+
- **`from:` on `debit` and `transfer_to`.** It names the credits to spend, as one credit or any list (a relation included) in the order it yields, and it is the one way to spend held value: a release gives held value back, `from:` takes it. The debit is allocated to the named credits in full or not at all, never to other credits and never to debt, whatever `allow_negative_balance` says. Held value needs no funding; with overdrafts off, the part taken from unheld credits must fit in the available balance, so naming a credit cannot spend what a reservation set aside or deepen a deficit. Every bad source raises `ArgumentError` before anything is written: an empty list, a credit named twice, another ledger's class, an unsaved, deleted or foreign credit, a debit, and an explicit `from: nil` (leave it out to spend any eligible credit). A spent or expired credit is not an error: it simply gives nothing and is never allocated from, and if the named credits then fall short the debit raises `Wallets::InsufficientBalance`. Holds stay as they were, and no `hold_released` fires. A transfer with `from:` is always fully backed, so it always keeps its sources' expirations under `:preserve`.
|
|
35
|
+
- **Readers.** On wallets: `held_balance`, `total_balance`, `available_balance` (the same as `balance`), `on_hold?` and `holds`. On transactions: `holds`, `held?`, `held_amount`, `available_amount` and `captured_reservation`. `Wallets::Hold` has the scopes `active(at:)`, `released`, `coverage`, `reservations`, `wallet_scoped` and `credit_scoped`, and the predicates `active?`, `released?`, `lapsed?`, `captured?`, `reservation?`, `coverage?`, `wallet_scoped?` and `credit_scoped?`. A hold reaches its credit through `hold.credit`: the column is `transaction_id`, but Active Record refuses an association named `transaction`.
|
|
36
|
+
- **Callbacks.** `on_hold_created` and `on_hold_released`, with the hold in `CallbackContext#hold`. `hold_created` reports the stored hold's category and creation metadata; `hold_released` fires for `release!` (with the release metadata) and for a capture (with the debit as `ctx.transaction`), never for a deadline. A hold or a reservation that lowers the available balance evaluates `low_balance_reached` and `balance_depleted` once, from the balances before and after; credits and releases never do. Embedders can rename or silence both through the `hold_created:` and `hold_released:` keys of `callback_event_map`.
|
|
37
|
+
- **`rails generate wallets:upgrade`.** Adds the holds table, and the wallets' `ledger_revision`, to 0.1, 0.2 and 0.3 installs. It honours `table_prefix` and reads key types (integer widths, unsigned, uuid) from the tables that already exist. Running it again never writes a duplicate migration, it writes none when both already exist, and the migration's `down` refuses to drop a holds table that still has holds (an empty one goes, with the column). `rails generate wallets:install` creates both on fresh installs.
|
|
38
|
+
- **`hold_class_name`, the capability switch for embedders.** It is `nil` on `Wallets::WalletBase` and `"Wallets::Hold"` on `Wallets::Wallet`. A ledger that has not opted in runs no hold SQL anywhere, destroy included, reads `held_balance` as 0, and raises `Wallets::HoldsNotEnabled` from every hold command before running a query. A ledger that opts in subclasses the new abstract `Wallets::HoldBase` with its own table; a missing table fails loudly instead of making held value spendable. `Wallets::HasMetadata` gains `has_metadata_column` for a second JSON column (`release_metadata`).
|
|
39
|
+
- `Wallets::InvalidHold` and `Wallets::HoldsNotEnabled`, both subclasses of `Wallets::Error`.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`balance` is now the available balance.** Held and reserved value is left out of it, exactly as expired value always was, and the cached `balance` column stores it too. Without holds every number is unchanged. Spending checks (`has_enough_balance?`, `debit`, `transfer_to`, your own `wallet.balance` gates) keep working; screens and reports that mean "everything the user has" should read `total_balance`.
|
|
44
|
+
- **The `balance >= 0` validation is gone.** With reservations, the available balance can go negative without any overdraft: reserve 80 of 100, then a fraud hold covers the 100. A save-time floor would reject that very hold, the next incoming credit, or the release. Commands enforce funding under the wallet lock instead. The configuration contract is now: *`allow_negative_balance` permits commands to exceed available funding; disabling it does not guarantee that available balance never goes negative through expiry or new restrictions.* The 0.2.0 note that turning the flag off leaves negative wallets unsaveable no longer applies: they stay saveable, take credits and releases, and refuse new spending.
|
|
45
|
+
- **The `:preserve` transfer fallback follows the actual unbacked portion.** A transfer now collapses to a single evergreen inbound credit (`:none`) only when part of its outbound debit is really unbacked, not whenever it exceeds the available balance. A fully backed transfer at zero available keeps its source expirations. This also changes behaviour without any hold: with older unbacked debt followed by fresh expiring credits, a transfer above the net available balance that those credits fully cover now keeps their expirations, where 0.2 and 0.3 delivered it evergreen.
|
|
46
|
+
- **`insufficient_balance` metadata gains `held:` and `reserved:`.** `held:` is the credit value under holds and `reserved:` the active reservations, so `available + held + reserved` is the total balance. Both keys are always present, and `0` on ledgers without holds. The error message names only the terms that are not zero: `Insufficient balance (20 < 40; 100 held, 30 reserved)`. A refused `from:` debit also reports `covered_consumed:` (the held value it would have taken), and a refused capture `available_for_capture:`; their messages say what was compared, such as `(100 < 101 in the named credits; ...)`, `(10 < 61 - 50 from held credits; ...)` or `(0 < 80 to capture reservation 5; ...)`. Every funding failure raises `Wallets::InsufficientBalance` and fires `insufficient_balance` once.
|
|
47
|
+
- **Every command increments the wallet's new `ledger_revision`**, even when the available balance does not move (a second reason on a held credit, a whole capture). It is how a stale MySQL snapshot is told from a current one, and unlike a timestamp no clock, `no_touching` or `touch(time:)` can repeat it; `updated_at` keeps ordinary Rails behaviour. The wallets table gains the column (bigint, not null, default 0): `wallets:install` creates it and `wallets:upgrade` adds it. A ledger that keeps holds requires it. An embedded ledger without holds whose table lacks it keeps working on the timestamp marker (a strictly increasing `updated_at`, compared with `balance`), which another connection's `touch(time:)` can still spoof; one `add_column` gives it the counter.
|
|
48
|
+
- **Success callbacks follow each command's own transaction.** They are queued on the savepoint every command now runs in, so a command that fails reports nothing. Inside `transaction(joinable: false)` they fire when the command's own transaction completes (Active Record's own `after_commit` rule), not at the outer commit.
|
|
49
|
+
- **`balance`, `held_balance` and `total_balance` are one SQL statement each** on ledgers with holds: the 0.3 sums, plus an indexed `EXISTS` per credit and one sum over active reservations, so their terms always share one snapshot. Ledgers without holds run the 0.3 queries.
|
|
50
|
+
- **`credit` takes two new keywords, `hold:` and `available_at:`, and `debit` takes `from:`.** An embedded ledger whose transaction table has columns by those names can no longer pass them to `credit` or `debit` as extra attributes.
|
|
51
|
+
- `positive_remaining_balance` and `unbacked_negative_balance(scope)` keep their 0.3 signatures and meaning. Held and reserved value live in separate terms, so overrides written against 0.3 keep working.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **A failed command no longer leaves partial writes behind a caller that rescues.** Every command now runs in its own savepoint. A transfer whose inbound leg fails, or a debit that fails after writing its row, leaves nothing behind and reports nothing, even when the caller rescues the error and commits its own transaction. The new hold commands follow the same rule.
|
|
56
|
+
- **Trilogy: the migrations treat it as MySQL.** Both the install and the upgrade migration recognised MySQL by an adapter name containing "mysql", so under Rails' Trilogy adapter they gave JSON columns a default MySQL refuses. They now match `/mysql|trilogy/i`, like the rest of the gem.
|
|
57
|
+
- **One instant per command.** Each command samples the time once, after its wallet locks are held, and reads expiry, deadlines, eligibility and snapshots at that instant. A credit expiring between the funding check and the allocation can no longer make an accepted debit fail.
|
|
58
|
+
- **MySQL: commands no longer decide on a stale snapshot.** Under repeatable read, a command called inside a transaction that had already read something could compute balances and pick credits from that first read's snapshot, missing what another transaction had committed to the wallet since. Each command now compares the wallet's `ledger_revision` in its snapshot with the locked row's and, when they differ, uses locking reads for the rest of the real database transaction, savepoints included; the verdict is dropped once that transaction ends. A wallet the caller saved or touched earlier in the same transaction counts as stale. Raw SQL writes to the wallet row are outside the supported API. Plain reads stay the default, because locking every read made unrelated wallets deadlock each other through InnoDB gap locks.
|
|
59
|
+
|
|
60
|
+
### Tests
|
|
61
|
+
|
|
62
|
+
- Test suite grew from 200 runs / 741 assertions to 532 runs / 2514 assertions on SQLite, with line coverage at 100% and branch coverage at 98.36%. The 31 tests that SQLite skips run on PostgreSQL and MySQL in CI: 29 race tests that need real concurrent connections, and the uuid and unsigned-key upgrade tests.
|
|
63
|
+
- Race tests on real PostgreSQL and MySQL connections: a transaction that reads first, waits for another to commit, then takes the wallet lock and still sees that commit (debits, holds, reservations, targeted debits, captures and transfers); a hold racing a debit for one credit settles on one outcome with no partial writes; concurrent releases make one transition; capture against release, and capture against capture, settle on one terminal transition with exactly one set of callbacks; concurrent debits on different wallets and concurrent sign-ups never deadlock.
|
|
64
|
+
- The upgrade generator runs up and down from a 0.3 schema and from the frozen 0.2 install migration, with a custom prefix, uuid keys, unsigned MySQL keys and historical key types, and the dummy app's own holds migration is the upgrade template, rendered.
|
|
65
|
+
- An embedded ledger proves both sides of the capability switch: no hold query at all without opting in, destroy included, and its own holds table end to end when it opts in.
|
|
66
|
+
- Every code sample in the README's holds section runs as a test (`ReadmeHoldsTest`).
|
|
67
|
+
|
|
68
|
+
## [0.3.0] - 2026-10-17
|
|
69
|
+
|
|
70
|
+
### Fixed
|
|
71
|
+
|
|
72
|
+
- **Host app model shadowing.** The engine no longer adds the gem's `lib` directories to the host app's autoload paths. Those paths made Zeitwerk claim top-level constants like `::Wallet`, `::Transaction`, `::Transfer`, and `::Allocation` — so any host app with its own model by one of those (very common) names found it shadowed and unresolvable (`NameError: uninitialized constant Transaction`). All gem code is required eagerly by `lib/wallets.rb`, so the autoload paths were never needed.
|
|
73
|
+
- **Owners with transfer history could not be destroyed.** `Wallets::Transfer#transactions` now uses `dependent: :nullify`. Previously, destroying a wallet (or its owner) that had ever sent or received a transfer raised `ActiveRecord::InvalidForeignKey`: the transfer row was destroyed while the counterparty's ledger row still pointed at it. Now the link object is cleared while both sides' ledger rows — and the counterparty's balance — survive intact; transaction metadata still carries `transfer_id` and counterparty details for audit.
|
|
74
|
+
- **`create_for_owner!` race recovery now works on PostgreSQL.** The duplicate-insert rescue ran inside the same transaction as the failed `INSERT`. On PostgreSQL a unique-index violation aborts that transaction, so the recovery `SELECT` raised `PG::InFailedSqlTransaction` instead of returning the winner's wallet — precisely in the concurrent-creation scenario the rescue exists for. The create now writes through a savepoint (`requires_new: true`) and rescues outside it, which also keeps a caller's surrounding transaction (e.g. `after_create` wallet auto-creation) usable after a lost race.
|
|
75
|
+
- **`belongs_to` requiredness is now explicit.** The gem's models load before Rails applies `belongs_to_required_by_default`, so all ledger associations were silently optional: an orphan `Transaction`/`Allocation`/`Transfer` passed validation and crashed later with a database-level `NotNullViolation`. All required associations now declare `optional: false` and fail with friendly validation errors regardless of host app configuration or load order.
|
|
76
|
+
- **`has_wallets` options now reach subclasses.** Wallet options were stored in a class-level ivar that STI/inheritance never saw, so a subclass of a model with `has_wallets default_asset: :coins` silently fell back to the global default asset. Options are now resolved lazily through the ancestor chain; subclasses inherit their parent's declaration and can override it with their own `has_wallets`. Lazy resolution also means `Wallets.configuration.default_asset` is honored no matter when the host app's initializer ran relative to model loading.
|
|
77
|
+
- **Unknown callback events no longer break ledger writes.** `Wallets::Callbacks.dispatch` promised error isolation but raised `NoMethodError` (mid-transaction!) when handed an event with no matching `on_<event>_callback` reader — e.g. from an embedded subclass with a custom `callback_event_map` but the default callbacks module. Unknown events are now ignored.
|
|
78
|
+
- `transfer_to` raises a friendly `Wallets::InvalidTransfer` ("Source wallet must be persisted") instead of a confusing `ArgumentError` from internal lock ordering when called on an unpersisted wallet.
|
|
79
|
+
- Amount validation no longer leaks internal errors: `credit`/`debit`/`transfer_to` with `Float::INFINITY`, `Float::NAN`, or a non-numeric like a `Symbol` now raise `ArgumentError` (previously `FloatDomainError`/`NoMethodError`), and `has_enough_balance?` returns `false` for them instead of crashing.
|
|
80
|
+
- `expires_at` given as a `String` is now validated by parsing it (`"2030-01-01"` works; garbage raises a clear `ArgumentError`). Previously any string — valid or not — crashed with "comparison of String with Time failed".
|
|
81
|
+
- `wallet()` on an unsaved owner raises `Wallets::Error` instead of a bare `RuntimeError`, so `rescue Wallets::Error` catches everything the gem raises.
|
|
82
|
+
- `Transaction.expired` scope and `Transaction#expired?` now treat a transaction expiring exactly "now" as expired (`<=` instead of `<`), matching `not_expired` and the balance math, so the two scopes partition the ledger cleanly at any instant.
|
|
83
|
+
- The install initializer template now lists the gem's real default categories (it previously mentioned `:transfer` and `:expiration`, which don't exist).
|
|
84
|
+
- `Appraisals` was out of sync with `gemfiles/` and CI (it listed Rails 6.1–8.0; the tested matrix is Rails 7.2 and 8.1).
|
|
85
|
+
|
|
86
|
+
### Changed
|
|
87
|
+
|
|
88
|
+
- `Wallet#history` orders by `created_at` with `id` as tiebreaker, so same-instant transactions (e.g. both legs of a transfer) have a deterministic order.
|
|
89
|
+
- `transfer_to` opens its transaction via the wallet class (`self.class.transaction`) instead of `ActiveRecord::Base.transaction`, so embedded wallet subclasses connected to a different database transact on the right connection.
|
|
90
|
+
- `Wallets::Railtie` is retained as a compatibility alias for `Wallets::Engine`; the engine remains the actual Rails integration point.
|
|
91
|
+
- `Wallets::Transfer` now validates `category` presence at the model layer instead of failing with a database `NOT NULL` violation.
|
|
92
|
+
- Successful lifecycle callbacks run only after the caller's outermost database transaction commits; a later rollback now drops them entirely. Rails 7.2's callback API is required for this guarantee.
|
|
93
|
+
- Ruby 3.2 and Rails 7.2.3.1 are now the minimum supported runtime versions. Current security-patched Rails dependency releases cannot be installed on Ruby 3.1, and earlier Rails 7.2 patch releases contain known vulnerabilities.
|
|
94
|
+
- Expiring value is consumed first (FEFO), with oldest-first ordering as the deterministic tie-breaker. Documentation now names this behavior accurately instead of calling every allocation FIFO.
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- `Wallets.normalize_asset_code(value)` — the single source of truth for asset code normalization (`" EUR "`, `:EUR`, and `"eur"` all name the same wallet), used consistently across configuration, wallets, transfers, and owner lookups.
|
|
99
|
+
- `Wallets::Embeddable` concern — the embeddability plumbing (`embedded_table_name`, `config_provider`, `resolved_config`, prefix-derived table names) extracted from the four models into one place. Embedded subclasses without an explicit `embedded_table_name` derive `"#{config.table_prefix}#{table_suffix}"` automatically.
|
|
100
|
+
- `Wallets::HasMetadata` concern — the indifferent-access metadata behavior (hash coercion, mutation-safe saves, NULL-column healing for MySQL) extracted from the three metadata-carrying models into one place.
|
|
101
|
+
- `Wallets::WholeNumber` — one strict integer parser shared by the core ledger and embedding gems, eliminating silent float/string truncation across amount and threshold boundaries.
|
|
102
|
+
- An explicit transaction-attribute allowlist for embedded ledgers, preventing extension keywords from overwriting core accounting fields.
|
|
103
|
+
|
|
104
|
+
### Tests
|
|
105
|
+
|
|
106
|
+
- Test suite grew from 92 runs / 338 assertions to 200 runs / 741 assertions. Line coverage 93.45% → 100%, branch coverage 65.93% → 98.91%; the SimpleCov gate is raised to 98% line / 85% branch.
|
|
107
|
+
- New regression tests for every fix above, including a real duplicate-insert race executed inside a caller's transaction (exercises PostgreSQL savepoint semantics in CI), destroy cascades with transfer history, FEFO allocation order, expiration boundary partitioning, amount/expiration edge cases, callback logging fallbacks, and STI wallet-option inheritance.
|
|
108
|
+
- The install generator now runs in tests, and its generated migration is executed (up and down) against the real database adapter in CI.
|
|
109
|
+
- Compatibility coverage includes Ruby 3.2 across both the Rails 7.2 and Rails 8.1 boundaries, Ruby 3.3/3.4/4.0 across both Rails lines, and clean migrations plus the full suite on SQLite, PostgreSQL, and MySQL.
|
|
110
|
+
- CI audits every supported dependency bundle against the latest `ruby-advisory-db` before release.
|
|
111
|
+
|
|
112
|
+
## [0.2.0] - 2026-05-03
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- `Wallet#transfer_to` now honors `Wallets.configuration.allow_negative_balance`, matching `Wallet#debit`. Previously the flag was half-applied: direct debits could go below zero, but the canonical transfer primitive (used to move value between users) silently rejected any transfer that would push the source below zero. Apps using the flag for a "convenience overdraft" (e.g. ride-fare apps where passengers may briefly go negative until rewards land) had to monkey-patch the gem to get consistent behavior. ([#3](https://github.com/rameerez/wallets/pull/3))
|
|
117
|
+
- `:balance_depleted` callback now fires when a debit takes a wallet from a positive balance to **zero or lower** (was: exactly zero). Previously, with `allow_negative_balance = true`, a single debit that drove a wallet from e.g. +100 to -50 would skip the callback because the wallet never landed on exact zero. The callback semantic is "ran out of available value", which is at least as true at -50 as at 0. With `allow_negative_balance = false` the behavior is unchanged because balances cannot go below zero.
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- When a transfer drives the source wallet below zero AND the caller is using the default `:preserve` expiration policy, the policy automatically falls back to `:none` for that transfer. Rationale: there are no positive source buckets to "preserve" expirations from for the deficit portion, and `build_preserved_transfer_inbound_credit_specs` would otherwise raise `InvalidTransfer`. The inbound credit becomes a single evergreen entry — the only honest representation of "value created without a source bucket". Explicit `:fixed` and `:none` policies are honored unchanged.
|
|
122
|
+
- `:insufficient_balance` callback no longer fires for transfers that succeed under `allow_negative_balance = true`. It still fires (and the transfer still raises `Wallets::InsufficientBalance`) when the flag is off and the transfer is rejected.
|
|
123
|
+
|
|
124
|
+
### Documented
|
|
125
|
+
|
|
126
|
+
- `apply_credit` does NOT auto-allocate a new credit against existing unbacked debits. The FIFO ledger is intentionally append-only — both the unbacked debit and the new credit persist as independent rows; `balance` reconciles them on the fly. Apps that want automatic debt settlement should layer that on in their own service code.
|
|
127
|
+
- `allow_negative_balance` is meant to be a stable config decision, not a runtime toggle. Flipping it OFF while wallets are below zero leaves them un-saveable, since the model's `balance >= 0` validation is gated on the flag and any subsequent `credit` / `debit` calls `refresh_cached_balance!` (which calls `save!`).
|
|
128
|
+
- `has_enough_balance?` keeps strict semantics under `allow_negative_balance = true`: it still answers "does this wallet have enough on hand right now?", returning `false` when the gem would happily complete an overdraft. Overdraft is a deliberate caller choice via `debit` / `transfer_to`, not a query semantic.
|
|
129
|
+
|
|
1
130
|
## [0.1.0] - 2026-03-18
|
|
2
131
|
|
|
3
132
|
Initial release.
|