Skip to content

Rebuild usage_credits on wallets ledger core (v1.0.0)#30

Open
rameerez wants to merge 3 commits intomainfrom
v1.0.0-wallets-core-integration
Open

Rebuild usage_credits on wallets ledger core (v1.0.0)#30
rameerez wants to merge 3 commits intomainfrom
v1.0.0-wallets-core-integration

Conversation

@rameerez
Copy link
Owner

Summary

This release rebuilds usage_credits on top of the new wallets gem, which provides the ledger core: balances, transactions, allocations, transfers, and expiration handling.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      usage_credits 1.0                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  Subscriptions, Credit Packs, Pay Integration, Fulfillment β”‚  β”‚
β”‚  β”‚  Operations DSL, Pricing, Refunds, Webhook Handling        β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                            β”‚                                    β”‚
β”‚                            β–Ό                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚                       wallets                              β”‚  β”‚
β”‚  β”‚    Balance, Credit, Debit, Transfer, Expiration, FIFO,    β”‚  β”‚
β”‚  β”‚    Audit Trail, Row-Level Locking, Multi-Asset            β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Architecture

  • UsageCredits::Wallet, Transaction, Allocation, Transfer now extend their Wallets::* counterparts using the embeddability hooks
  • Each subclass sets embedded_table_name, config_provider, callbacks_module, and related model class names
  • Both gems can coexist in the same Rails app without table/config collision
  • Cross-class transfers are rejected (UsageCredits::Wallet cannot transfer to Wallets::Wallet)

Key Changes

New Files

  • lib/usage_credits/models/transfer.rb β€” credit transfers between users
  • lib/generators/usage_credits/upgrade_generator.rb β€” upgrade migration for pre-1.0 installs
  • test/integration/coexistence_test.rb β€” verifies both gems work independently

Schema Updates

  • Migration templates updated with new schema:
    • expiration_policy column on transfers (preserve/none/fixed)
    • transfer_id FK on transactions (no singular outbound/inbound FKs)
    • asset_code column on wallets (default: "credits")
    • All value columns now bigint

Model Changes

  • Wallet/Transaction/Allocation now extend Wallets::* base classes
  • Re-declared associations point to UsageCredits::* classes
  • Callback event mapping: credited β†’ credits_added, debited β†’ credits_deducted, etc.

Backwards Compatibility

All existing API preserved:

# Still works exactly the same
user.credits                           # => 1000
user.give_credits(500, reason: "bonus")
user.spend_credits_on(:send_email)
user.has_enough_credits_to?(:generate_report)
user.credit_history

Existing installs run the upgrade migration to add new columns/tables.


New Capabilities from Wallets Core

Feature Description
Transfer expiration policies :preserve (default), :none, :fixed
Multi-bucket transfer splitting Preserves source expirations across multiple inbound legs
Row-level locking Prevents race conditions on concurrent operations
Balance snapshots balance_before / balance_after on every transaction

Test Results

729 runs, 1756 assertions, 0 failures, 0 errors, 0 skips

Test Plan

  • All 729 tests pass
  • Coexistence test verifies both gems work independently
  • Upgrade migration test verifies schema changes
  • Migration template test verifies correct schema generation
  • Backwards compatibility preserved for all existing API

πŸ€– Generated with Claude Code

This release rebuilds usage_credits on top of the new `wallets` gem,
which provides the ledger core: balances, transactions, allocations,
transfers, and expiration handling.

Architecture:
- UsageCredits::Wallet, Transaction, Allocation, Transfer now extend
  their Wallets::* counterparts using the embeddability hooks
- Each subclass sets embedded_table_name, config_provider, callbacks_module,
  and related model class names to maintain full isolation
- Both gems can coexist in the same Rails app without table/config collision

Key changes:
- Add `wallets` gem dependency
- Wallet/Transaction/Allocation models now extend Wallets::* base classes
- Add Transfer model for credit transfers between users
- Add upgrade generator for pre-1.0 installs (asset_code, bigint columns,
  transfers table, transfer_id on transactions)
- Migration templates updated with new schema (expiration_policy on
  transfers, no singular outbound/inbound transaction FKs)
- Coexistence test verifies both gems work independently in same app

Backwards compatibility:
- All existing API preserved: give_credits, deduct_credits, spend_credits_on
- credits, credit_history, has_enough_credits_to? unchanged
- Existing installs run upgrade migration to add new columns/tables

New capabilities from wallets core:
- Transfer expiration policies (preserve/none/fixed)
- Multi-bucket transfer splitting for expiration preservation
- Row-level locking for concurrent operations
- Balance snapshots on transactions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 18, 2026

placeholder test comment - will be replaced

@claude
Copy link

claude bot commented Mar 18, 2026

ignore this test comment

@claude
Copy link

claude bot commented Mar 18, 2026

ignore

@claude
Copy link

claude bot commented Mar 18, 2026

multi line
body test

@claude
Copy link

claude bot commented Mar 18, 2026

heading test:
this line has content
and the next line would be a heading but I cant test that inline

- Properly set self.credit_wallet after find/create in ensure_credit_wallet
- Gemspec: better file excludes, add Rails < 9.0 ceiling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 18, 2026

line 1
## Heading test
line 3 content

@claude
Copy link

claude bot commented Mar 18, 2026

line 1

HTML Heading Test

line 3 content

@claude
Copy link

claude bot commented Mar 18, 2026

Code Review β€” PR #30: Rebuild on wallets ledger core (v1.0.0)

This is a substantial architectural refactor. The layering is clean and the backwards-compat story is well-thought-out. A few things worth addressing before merging:


Potential Bugs

1. deduct_credits return value not verified

The new wrapper returns whatever Wallets::Wallet#debit returns. The old implementation explicitly returned the spend_tx. Some callers (e.g., spend_credits_on) depend on the return value being the transaction record. If the parent's debit returns something different this would be a silent breakage β€” worth an explicit test or doc note confirming the contract.

2. Upgrade migration has no down method

upgrade_usage_credits_to_wallets_core.rb.erb defines only up. rails db:rollback after running this migration will silently no-op. Consider adding def down; raise ActiveRecord::IrreversibleMigration; end to make the intent explicit rather than allowing a broken rollback.

3. TOCTOU in ensure_credit_wallet

wallet = original_credit_wallet || UsageCredits::Wallet.find_by(owner: self, asset_code: "credits")
return wallet if wallet.present?
UsageCredits::Wallet.create_for_owner!(...)

Two concurrent requests could both get nil from both checks and race to create_for_owner!. The unique index means the second will fail at the DB level, but the resulting ActiveRecord::RecordNotUnique won't be rescued gracefully. A rescue ActiveRecord::RecordNotUnique + re-lookup would harden this edge case.


Code Quality

4. CATEGORIES = DEFAULT_CATEGORIES is redundant

In transaction.rb, both constants now point to the same frozen object. The original code had a comment explaining CATEGORIES was a backwards-compat alias β€” that comment was removed in this PR. Either restore the comment or remove CATEGORIES if nothing external references it.

5. "credits" string is scattered across ~10 locations

The asset code "credits" is hard-coded in has_wallet.rb, wallet.rb, test fixtures, and migration templates. Extracting it to a constant (e.g., UsageCredits::DEFAULT_ASSET_CODE = "credits") would reduce typo risk and make future changes trivial.

6. table_prefix in Configuration looks like dead code

The table_prefix method always returns "usage_credits_" but the actual table routing is done via embedded_table_name on each model class. If this method is not part of the wallets embeddability API contract, it should be removed to avoid confusion.

7. UpgradeGenerator does not guard against duplicate migrations

Running rails generate usage_credits:upgrade twice produces two migration files with different timestamps. Consider checking for an existing upgrade migration file before generating a new one.


Architecture / Design

8. wallets pinned to ~> 0.2 (pre-1.0 gem)

Pre-1.0 gems conventionally allow breaking changes in minor bumps. The ~> 0.2 constraint locks out 0.3 even for pure bug fixes. Document this coupling so downstream consumers understand they will need a usage_credits update to pick up wallets fixes beyond 0.2.x.

9. transfer_completed: nil silently drops the event

Users who configure on_transfer_completed in UsageCredits.configure will get a silent no-op. There is a test confirming this is intentional, but there is no user-facing documentation. A brief note in the README that transfer callbacks are only available at the wallets gem layer would prevent confusion.


Testing

10. No test for transfer_credits_to (the backwards-compat wrapper)

wallet.rb introduces transfer_credits_to as a backwards-compat alias for transfer_to with error translation (Wallets::InvalidTransfer to UsageCredits::InvalidTransfer). All new tests call transfer_to directly. A test for the wrapper and its error mapping would be worth adding.

11. CoexistenceTest setup only cleans wallets-side tables

The setup block only calls Wallets::Wallet.where(owner_type: "Team").delete_all. A comment explaining why usage_credits-side cleanup is not needed (i.e., UsageCredits::Wallet records for Teams cannot exist given the model separation) would make the intent clearer.


Minor

  • schema.rb bumps to ActiveRecord::Schema[8.1]. Teams on Rails 7.x will see a diff if they regenerate their schema. Worth a note in the migration guide.
  • usage_credits_allocations in schema.rb still has an index named index_allocations_on_transaction_id (old naming convention) alongside the new properly-namespaced indexes. Not a functional issue, but worth fixing for consistency.

Overall this is solid work. The embeddability pattern is clean, the coexistence test is genuinely useful, and the upgrade migration test using a real SQLite database is exactly the right approach for verifying data-preserving migrations. The main items to address before merging are the missing down method (item 2), the wallets coupling documentation (item 8), and the "credits" constant extraction (item 5) to reduce future maintenance burden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Mar 18, 2026

Code Review: PR #30 β€” Rebuild usage_credits on wallets ledger core (v1.0.0)

This is a substantial, well-structured refactor. The layering strategy (extending Wallets::* base classes, re-declaring associations with correct namespaced classes, mapping events via callback_event_map) is clean and the coexistence test is a great addition. A few things worth addressing before merging:


πŸ”΄ High Priority

1. credits method may silently break the backwards-compatibility contract

wallet.rb now implements credits as:

def credits
  balance
end

The comment above it says:

usage_credits historically floors negative balances to zero even when allow_negative_balance is enabled. Keep that contract for backwards compatibility.

But the implementation doesn't enforce this floor. If the wallets layer allows balance to go negative (when allow_negative_balance is true), callers who previously received 0 will now receive a negative number. The old code was:

.yield_self { |sum| [sum, 0].max }.to_i

If the intent is truly to preserve that contract, it should be:

def credits
  [balance, 0].max
end

…or the comment should be revised to say the floor is intentionally removed.


2. add_credits passes fulfillment: to the wallets gem's credit() β€” is that supported?

wallet.rb:

def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil)
  credit(
    amount,
    metadata: metadata,
    category: category,
    expires_at: expires_at,
    fulfillment: fulfillment   # ← is Wallets::Wallet#credit expecting this?
  )
end

If Wallets::Wallet#credit doesn't accept a fulfillment: keyword, this will raise ArgumentError: unknown keyword: fulfillment at runtime for every subscription fulfillment. This path is exercised heavily (subscription credits, credit packs). Worth double-checking the wallets gem's credit method signature.


3. Upgrade migration has no down method

upgrade_usage_credits_to_wallets_core.rb.erb only defines up. This means db:rollback will raise a IrreversibleMigration (or silently do nothing, depending on Rails version). Pre-1.0 users who run the upgrade and hit a problem can't roll back automatically. Either add a down that reverses each step, or explicitly mark it irreversible:

def down
  raise ActiveRecord::IrreversibleMigration, "Cannot roll back the 1.0 upgrade. Restore from a database backup."
end

🟑 Medium Priority

4. Locking behavior in deduct_credits β€” is it preserved?

The original deduct_credits wrapped everything in with_lock do with FOR UPDATE on the positive transaction rows. The new version delegates entirely to debit(...) from the wallets gem. Whether the wallets gem provides equivalent row-level locking is critical for preventing double-spending under concurrency. Worth making this explicit in a comment (or the wallets gem's docs) so future maintainers know the lock guarantee isn't lost.

5. table_prefix leaks an internal detail into the public Configuration API

configuration.rb:

def table_prefix
  "usage_credits_"
end

The comment says this is for wallets gem compatibility and is always "usage_credits_". If it's not user-configurable (and can't be changed without breaking things), it shouldn't be on Configuration as a public method β€” it gives the false impression it can be overridden. Consider moving it to an internal constant or the model layer.

6. transfer_to vs transfer_credits_to β€” two surfaces for the same operation

The README documents the wallet-level API as credit_wallet.transfer_to(...), which calls the method inherited from Wallets::Wallet directly. But there's also transfer_credits_to which wraps it. These two paths have different error types raised. Callers using the direct transfer_to won't get the InsufficientCredits/InvalidTransfer translation. Either document which one to use, or have transfer_to be overridden to go through the wrapper.


🟒 Low / Nits

7. Schema version vs. migration version mismatch

schema.rb declares ActiveRecord::Schema[8.1] but new migration files use ActiveRecord::Migration[7.2]. This is cosmetic in the test dummy, but could cause warnings or confusion in apps on Rails 7.x that run the new migrations.

8. ensure_credit_wallet double-lookup

wallet = original_credit_wallet || UsageCredits::Wallet.find_by(owner: self, asset_code: "credits")
if wallet.present?
  self.credit_wallet = wallet unless original_credit_wallet == wallet
  return wallet
end

When original_credit_wallet is nil, the unless nil == wallet is always true, making the conditional redundant. Minor, but it adds noise.

9. Old empty_wallet fixture removal

The empty_wallet fixture (user 3, balance 0) was removed. If any test was relying on user 3 having no wallet for "wallet creation" tests, that scenario is now covered by the new walletless_user fixture. Just confirm no test references empty_wallet explicitly (looks like it was fully cleaned up, but worth a grep).


βœ… What's working well

  • The callback_event_map approach for translating wallets events to usage_credits events is elegant and makes the mapping explicit.
  • Re-declaring associations with namespaced class names rather than relying on STI-style inference is the right call.
  • The coexistence test (test/integration/coexistence_test.rb) is thorough and directly validates the multi-gem scenario.
  • The upgrade migration covers all the schema changes (bigint columns, asset_code, transfers table) needed for pre-1.0 installs.
  • CI change to run db:migrate:reset before tests ensures schema drift is caught early.

Overall this is a clean layering. The credits floor issue (#1) and the fulfillment: parameter (#2) are the ones most likely to cause silent regressions in production.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant