Skip to content

Add configurable transaction categories for money-like wallet use cases#29

Merged
rameerez merged 3 commits intomainfrom
feature/additional-transaction-categories
Mar 15, 2026
Merged

Add configurable transaction categories for money-like wallet use cases#29
rameerez merged 3 commits intomainfrom
feature/additional-transaction-categories

Conversation

@rameerez
Copy link
Copy Markdown
Owner

Why this matters

While building a marketplace app, I realized usage_credits has all the infrastructure needed for a proper wallet system — double-entry ledger, row-level locking, FIFO allocation, audit trails — but the hardcoded transaction categories (signup_bonus, operation_charge, etc.) are specific to the "API credits" use case.

Many apps need the same ledger infrastructure but for different domains:

  • Marketplaces: seller balances, payouts, platform fees
  • Fintech: deposits, withdrawals, transfers
  • Gaming: in-game currency, rewards, purchases
  • Loyalty programs: points earned, redeemed, expired

Rather than building a separate wallet system from scratch, it makes sense to extend this gem to support these use cases.

What this PR does

Adds config.additional_categories to let apps define their own transaction categories:

UsageCredits.configure do |config|
  config.additional_categories = %w[
    payment_received
    payment_sent
    payout_requested
    platform_fee
    refund
  ]
end

These work exactly like built-in categories — validated, tracked in history, available for filtering.

Changes

  • Configuration: Added additional_categories option (defaults to empty array)
  • Transaction model: Added Transaction.categories class method that combines DEFAULT_CATEGORIES + custom ones
  • Validation: Uses dynamic lookup via lambda instead of frozen constant
  • Tests: 5 new tests covering the functionality
  • README: New "Beyond credits" section with marketplace example and money-handling documentation

Backwards compatible

  • CATEGORIES constant still exists (points to DEFAULT_CATEGORIES)
  • Default behavior unchanged — no custom categories unless configured
  • All existing tests pass

Example use case (from README)

# Marketplace seller payout
@seller.wallet.add_credits(
  seller_amount,
  category: :payment_received,
  metadata: {
    order_id: @order.id,
    gross_amount: @order.total_cents,
    platform_fee: platform_fee
  }
)

Test plan

  • All existing transaction tests pass (80 tests, 0 failures)
  • New tests for additional_categories pass (5 tests, 17 assertions)
  • Custom categories validate correctly
  • Transaction.categories includes both default + custom
  • Backwards compatibility: CATEGORIES constant unchanged

🤖 Generated with Claude Code

Allow apps to extend the default transaction categories with custom ones
via `config.additional_categories`. This enables using the gem for
marketplace wallets, seller payouts, and other money-handling systems
beyond traditional API credits.

Changes:
- Add `additional_categories` config option (array of strings)
- Add `Transaction.categories` class method combining defaults + custom
- Update validation to use dynamic category lookup
- Add comprehensive tests for the new functionality
- Document "Beyond credits" use cases in README with marketplace example

Backwards compatible: CATEGORIES constant still works, defaults unchanged.

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

claude bot commented Mar 13, 2026

PR Review: Add configurable transaction categories

rameerez and others added 2 commits March 15, 2026 02:22
- Filter out blank/nil values in additional_categories setter
- Deduplicate categories when combining defaults with custom ones
- Add tests for validation edge cases per PR review feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rameerez rameerez merged commit a9ded56 into main Mar 15, 2026
@claude
Copy link
Copy Markdown

claude bot commented Mar 15, 2026

PR Review: Add configurable transaction categories

Overall this is a clean, well-motivated PR. The implementation is minimal, backward-compatible, and well-tested. A few observations below.


What works well

  • Backward compatibility is solid: CATEGORIES alias to DEFAULT_CATEGORIES means existing code keeps working, and the teardown-based test isolation (UsageCredits.reset!) ensures tests don't bleed state into each other.
  • Validation in the setter (type check, blank filtering, .to_s.strip) handles edge cases like nil, empty strings, and symbols gracefully.
  • Test coverage is thorough: 9 tests covering happy path, type coercion, deduplication, blank filtering, backwards compat, and the “invalid without config” negative case.
  • README section is genuinely useful — concrete marketplace example, mentions locking, explains the multi-currency limitation.

Issues / suggestions

1. CATEGORIES constant is now a misleading validator (minor, but real)

Any existing code checking membership against Transaction::CATEGORIES (e.g. in app-level guards or admin UIs) will silently miss custom categories:

Transaction::CATEGORIES.include?(:payment_received)  # => false, even after configuration

The comment says “prefer using Transaction.categories” but nothing stops users from continuing to use the constant and getting subtly wrong results. Worth a stronger inline warning:

# DEPRECATED: Only contains built-in defaults. Use Transaction.categories (the method, not this constant)
# to get all valid categories including any configured via additional_categories.
CATEGORIES = DEFAULT_CATEGORIES

2. Transaction.categories is called on every validation — no memoization

def self.categories
  (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq
end

This does an array concat + uniq on every record save. Since additional_categories only changes at boot time, this result is constant for the lifetime of a process. Memoizing would avoid the repeated work:

def self.categories
  @categories ||= (DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq
end

You would need to clear @categories inside UsageCredits.reset! for tests to keep working. Low priority if the category list stays small, but easy wins are easy wins.

3. No validation of category name format

config.additional_categories = ["hello world", "has-dashes", "123starts_with_number"]

All of these are accepted today. The built-in categories are all lowercase snake_case, and titleize in the description helper works on that assumption. Consider either validating format (/\A[a-z][a-z0-9_]*\z/) or at least documenting the snake_case convention. Optional, but failing fast beats a confusing display bug.

4. README request_payout example has a TOCTOU race (documentation)

The comment says “In production, wrap in wallet.with_lock” but the example code does not do that — someone copy-pasting this will ship a race condition. Better to show it locked:

def request_payout(amount_cents)
  wallet.with_lock do
    raise "Insufficient balance" if credits < amount_cents
    wallet.deduct_credits(amount_cents, category: :payout_requested, ...)
  end
end

Summary

The core implementation is sound — lambda-based inclusion validator, setter validation, and test coverage are all done right. The main things to fix before merge: stronger deprecation note on the CATEGORIES constant (issue 1) and the README locking example showing actual locking (issue 4). Issues 2 and 3 are optional polish.

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