From f9f807016d7b760a068410da03f1821c87d1b651 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:04:14 +0000 Subject: [PATCH 1/2] Add embeddability hooks and transfer expiration policies (v0.2.0) This release makes wallets embeddable by other gems (like usage_credits) and adds expiration-aware transfers that preserve source bucket expirations by default. Embeddability: - Wallet, Transaction, Allocation, Transfer models now support subclassing with overridable table names, config providers, callbacks, and related model class names via class_attribute hooks - Multiple wallet systems can coexist in the same Rails app without collision - Transfers now enforce same-class constraint to prevent accidental cross-system transfers Transfer expiration policies: - New `expiration_policy` column on transfers (preserve/none/fixed) - :preserve (default) keeps source bucket expirations; splits into multiple inbound legs when consuming buckets with different expirations - :none creates evergreen inbound credits - :fixed applies a specific expires_at to all inbound credits - Configurable default via `config.transfer_expiration_policy` Schema changes: - Added expiration_policy column to transfers table - Removed outbound_transaction_id/inbound_transaction_id FKs from transfers (now derived via transfer_id on transactions to support multiple inbound legs) Rails compatibility: - Extended support back to Rails 6.1 (was 7.2+) - Added Appraisals for Rails 6.1, 7.0, 7.1, 7.2, 8.0 CI improvements: - Test workflow now runs db:migrate:reset before tests to catch migration template / schema drift early Documentation: - Major README rewrite with wallets vs usage_credits comparison - Real-world examples: telecom, games, marketplaces, loyalty, gig economy - Transfer expiration behavior fully documented Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 6 +- Appraisals | 16 +- CHANGELOG.md | 6 + README.md | 494 +++++++++++++++--- .../templates/create_wallets_tables.rb.erb | 5 +- lib/wallets/configuration.rb | 11 + lib/wallets/models/allocation.rb | 13 +- lib/wallets/models/transaction.rb | 32 +- lib/wallets/models/transfer.rb | 72 ++- lib/wallets/models/wallet.rb | 334 +++++++++--- lib/wallets/version.rb | 2 +- .../20250212181807_create_wallets_tables.rb | 4 +- test/dummy/db/schema.rb | 7 +- test/integration/embeddability_test.rb | 363 +++++++++++++ test/models/wallets/transfer_test.rb | 172 +++++- test/wallets/configuration_test.rb | 4 + test/wallets/install_templates_test.rb | 3 + wallets.gemspec | 3 +- 18 files changed, 1364 insertions(+), 183 deletions(-) create mode 100644 test/integration/embeddability_test.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad3b3a2..73c1336 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,8 +40,10 @@ jobs: ruby-version: ${{ matrix.ruby_version }} bundler-cache: true - - name: Run tests - run: bundle exec rake test + - name: Prepare database and run tests + # Exercise the real migration path in SQLite too so template/schema + # drift is caught in the default matrix, not only adapter-specific jobs. + run: bundle exec rake db:migrate:reset test - name: Upload test results if: failure() diff --git a/Appraisals b/Appraisals index 7250474..5bd1904 100644 --- a/Appraisals +++ b/Appraisals @@ -1,9 +1,21 @@ # frozen_string_literal: true +appraise "rails-6.1" do + gem "rails", "~> 6.1.0" +end + +appraise "rails-7.0" do + gem "rails", "~> 7.0.0" +end + +appraise "rails-7.1" do + gem "rails", "~> 7.1.0" +end + appraise "rails-7.2" do gem "rails", "~> 7.2.0" end -appraise "rails-8.1" do - gem "rails", "~> 8.1.0" +appraise "rails-8.0" do + gem "rails", "~> 8.0.0" end diff --git a/CHANGELOG.md b/CHANGELOG.md index 115e75f..aebb272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [0.2.0] - 2026-03-18 + +- Add embeddability hooks so other gems, including `usage_credits`, can reuse the wallet ledger core with their own tables, callbacks, and configuration +- Widen stored ledger values to `bigint` and harden transfer/class isolation for coexistence in the same Rails app +- Expand the runtime test suite and documentation for production-ready internal balances, transfers, and multi-asset wallet use cases + ## [0.1.0] - 2026-03-15 - Extract the neutral wallet ledger core from `usage_credits` diff --git a/README.md b/README.md index 2a912f0..d952ffa 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,105 @@ Use it for: -- Multi-currency balances like `:eur`, `:usd`, or `:gbp` -- Game resources like `:wood`, `:stone`, `:gems`, or `:gold` -- Store credit, reward wallets -- Transferrable usage between users, like a SIM card app: "this plan gives you X GBs per month, you can transfer any unused GBs to other users" -- Marketplace seller balances and platform credits -- Gig economy earnings, rider/driver balances, or reward wallets -- Cashback, loyalty points, store credit, and in-app tokens +- **Telecom / data plans** — "This plan gives you 10 GB per month, transfer unused data to friends" +- **Game resources** — Wood, stone, gems, gold, energy — any virtual economy +- **Multi-currency balances** — EUR, USD, GBP wallets per user +- **Marketplace balances** — Seller earnings, buyer credits, platform payouts +- **Rewards & loyalty** — Cashback, points, store credit, referral bonuses +- **Gig economy** — Driver earnings, rider credits, tip wallets -> [!TIP] -> If your product is specifically about usage-based credits for SaaS, APIs, or AI apps, [`usage_credits`](https://github.com/rameerez/usage_credits) is probably the better fit. `usage_credits` uses `wallets` underneath, and then adds handy DX ergonomics for credits, fulfillment, pricing, refills, packs, subscriptions, payment flows, and more. The `wallets` gem was extracted from `usage_credits`, and should only be used for when you need something like `usage_credits`, minus the credits-specific overhead. wallets = ledger/balance core; usage_credits = opinionated acquisition/product layer (subscriptions, packs/top-ups, Pay integration, recurring fulfillment) +``` +┌─────────────────────────────────────────────────────────────────┐ +│ wallets │ +│ │ +│ The ledger core: balances, transactions, transfers, expiry │ +│ │ +│ user.wallet(:gb).credit(10_240, expires_at: month_end) │ +│ user.wallet(:gb).transfer_to(friend.wallet(:gb), 3_072) │ +│ user.wallet(:gb).debit(512, category: :network_usage) │ +│ user.wallet(:gb).balance # => 6656 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## wallets vs usage_credits — which gem do I need? + +Both gems handle balances, but they solve different problems: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ usage_credits │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ 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 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +| Aspect | `wallets` | `usage_credits` | +|--------|-----------|-----------------| +| **Core job** | Store and move value | Sell and consume value | +| **Balance model** | Multi-asset (`:gb`, `:eur`, `:gems`) | Single asset (credits) | +| **Consumption** | Passive — balance depletes over time | Active — `spend_credits_on(:operation)` | +| **Transfers** | Built-in between users | Not designed for this | +| **Subscriptions** | You handle externally | Built-in with Stripe via `pay` | +| **Operations DSL** | None | `operation :send_email { costs 1.credit }` | +| **Best for** | B2C: games, telecom, rewards, marketplaces | B2B: SaaS, APIs, AI apps | + +### When to use `wallets` alone + +Use `wallets` directly when your product: +- Needs **multiple asset types** — `user.wallet(:wood)`, `user.wallet(:gold)`, `user.wallet(:eur)` +- Has **passive consumption** — balance depletes from usage over time (data, minutes, energy) +- Needs **user-to-user transfers** — gifting, P2P payments, marketplace settlements +- Manages its own subscription logic — or doesn't need subscriptions at all + +### When to use `usage_credits` + +Use `usage_credits` when your product: +- Sells **credits for specific operations** — "Process image costs 10 credits" +- Needs **Stripe subscriptions** with automatic credit fulfillment +- Wants the **operations DSL** — `spend_credits_on(:generate_report)` +- Is a **B2B/SaaS/API product** with usage-based pricing + +### When to use both together -`wallets` is a good fit for software that needs more than `users.balance += 1`, but does not need a full banking core. +For products like a **SIM/telecom app**, you might use both: -Think: +```ruby +# usage_credits handles ACQUISITION (how users get balance) +subscription_plan :basic_data do + stripe_price "price_xyz" + gives 10_000.credits.every(:month) # 10 GB in MB +end -- Games like Fortnite or FarmVille where users collect and spend different resources -- Marketplace flows like Etsy or Fiverr where users accrue balances and spend or transfer value internally -- Reward and gig apps in the style of DoorDash or Uber where users earn balance from actions over time +# wallet-level movement is still available underneath usage_credits +user.credit_wallet.transfer_to(friend.credit_wallet, 3_000) # Gift 3 GB +user.credit_wallet.balance # => 7000 MB remaining +``` + +> [!TIP] +> `usage_credits` 1.0 uses `wallets` as its ledger core. If you only need `usage_credits`, you get `wallets` for free underneath. Wallet-level methods like `user.credit_wallet.transfer_to(...)` are still available there, but the transfer DX intentionally lives at the wallet layer rather than the credits DSL. ## Why this gem -`wallets` is built around a few practical ideas: +`wallets` gives you more than `users.balance += 1`, but less than a full banking system: -- One owner can have one wallet per asset: `user.wallet(:usd)`, `user.wallet(:eur)` -- Every balance change is tracked as a transaction -- Debits allocate against the oldest available credits first, so expiring value gets consumed first -- Transfers create linked records on both sides -- Row-level locking protects concurrent debits and transfers -- Metadata and balance snapshots give you a useful audit trail +| Feature | What it does | +|---------|--------------| +| **Multi-asset** | One wallet per asset: `user.wallet(:usd)`, `user.wallet(:gems)` | +| **Append-only ledger** | Every balance change is a transaction — no edits, only new entries | +| **FIFO allocation** | Debits consume oldest credits first (important for expiring balances) | +| **Linked transfers** | Both sides of a transfer are recorded and queryable | +| **Row-level locking** | Prevents race conditions and double-spending | +| **Balance snapshots** | Each transaction records before/after balance for reconciliation | +| **Rich metadata** | Attach any JSON to transactions for audit and filtering | ## Quick start @@ -167,10 +237,21 @@ transfer = sender.transfer_to( ) transfer.outbound_transaction -transfer.inbound_transaction +transfer.inbound_transactions ``` -Transfers require both wallets to use the same asset. `:eur` can move to `:eur`; `:wood` can move to `:wood`. +Transfers require both wallets to use the same asset and the same wallet class. `:eur` can move to `:eur`; `:wood` can move to `:wood`; `Wallets::Wallet` cannot transfer directly to `UsageCredits::Wallet`. + +> [!NOTE] +> **Transfer expiration behavior:** Transfers preserve expiration buckets by default. If a single transfer consumes multiple source buckets with different expirations, the receiver gets multiple inbound credit transactions so those expirations remain intact. +> +> You can override that per transfer: +> +> ```ruby +> sender.transfer_to(receiver, 100, expiration_policy: :none) # evergreen on receive +> sender.transfer_to(receiver, 100, expires_at: 30.days.from_now) # fixed expiration on receive +> sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now) +> ``` ### Expiring balances @@ -204,6 +285,7 @@ Wallets.configure do |config| config.allow_negative_balance = false config.low_balance_threshold = 50 + config.transfer_expiration_policy = :preserve end ``` @@ -246,72 +328,346 @@ Useful fields on `ctx` include: - `ctx.category` - `ctx.metadata` -## Real-world fit +## Real-world examples + +### Telecom / Mobile data app + +A SIM card app where users get monthly data and can transfer unused GBs to friends: + +```ruby +class User < ApplicationRecord + has_wallets default_asset: :data_mb # Store in MB for precision +end + +# Monthly plan grants 10 GB (stored as 10,240 MB) +user.wallet(:data_mb).credit( + 10_240, + category: :monthly_plan, + expires_at: 1.month.from_now, + metadata: { plan: "basic", period: "2024-03" } +) + +# Network usage consumes data passively +user.wallet(:data_mb).debit(512, category: :network_usage) + +# User transfers 3 GB to a friend +user.wallet(:data_mb).transfer_to( + friend.wallet(:data_mb), + 3_072, + category: :gift, + metadata: { message: "Here's some extra data!" } +) + +user.wallet(:data_mb).balance # => 6656 MB (6.5 GB remaining) +``` + +> [!NOTE] +> Store data in the smallest practical unit (MB or KB, not GB as a float). `wallets` uses integers to avoid floating-point issues. + +### Game economy + +A farming/strategy game with multiple resources: + +```ruby +class Player < ApplicationRecord + has_wallets default_asset: :gold +end + +# Quest rewards multiple resources +player.wallet(:wood).credit(100, category: :quest_reward, metadata: { quest: "forest_patrol" }) +player.wallet(:stone).credit(50, category: :quest_reward) +player.wallet(:gold).credit(25, category: :quest_reward) + +# Crafting consumes resources +player.wallet(:wood).debit(30, category: :crafting, metadata: { item: "wooden_sword" }) + +# Premium currency from in-app purchase +player.wallet(:gems).credit(500, category: :purchase, metadata: { sku: "gem_pack_500" }) + +# Seasonal event with expiring currency +player.wallet(:snowflakes).credit( + 1_000, + category: :event_reward, + expires_at: Date.new(2024, 1, 7) # Winter event ends +) + +# Trading between players +player.wallet(:gold).transfer_to( + other_player.wallet(:gold), + 100, + category: :trade, + metadata: { item_received: "rare_armor" } +) +``` + +### Marketplace with seller balances + +An Etsy/Fiverr-style marketplace where sellers earn and can withdraw: + +```ruby +class User < ApplicationRecord + has_wallets default_asset: :usd_cents +end + +# Order completed — credit seller (minus platform fee) +order_total = 5000 # $50.00 +platform_fee = (order_total * 0.10).to_i # 10% +seller_earnings = order_total - platform_fee + +seller.wallet(:usd_cents).credit( + seller_earnings, + category: :sale, + metadata: { + order_id: order.id, + gross_amount: order_total, + platform_fee: platform_fee, + buyer_id: buyer.id + } +) + +# Buyer uses store credit +buyer.wallet(:usd_cents).debit( + 2000, + category: :purchase, + metadata: { order_id: order.id } +) + +# Seller requests payout +seller.wallet(:usd_cents).debit( + seller.wallet(:usd_cents).balance, + category: :payout, + metadata: { stripe_transfer_id: "tr_xxx" } +) + +# Transaction history for accounting +seller.wallet(:usd_cents).history.each do |tx| + puts "#{tx.created_at}: #{tx.category} #{tx.amount} cents" + puts " Balance: #{tx.balance_before} → #{tx.balance_after}" +end +``` + +### Loyalty programs & Reward points + +Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app — it's the same pattern: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Loyalty program flow │ +├─────────────────────────────────────────────────────────────┤ +│ EARN │ Purchase, action, referral, promo │ +│ HOLD │ Points accumulate, some may expire │ +│ TRANSFER │ Gift to family, pool with friends │ +│ REDEEM │ Rewards, discounts, gift cards │ +└─────────────────────────────────────────────────────────────┘ +``` + +```ruby +class User < ApplicationRecord + has_wallets default_asset: :points +end + +# ═══════════════════════════════════════════════════════════ +# EARN — from purchases, actions, referrals +# ═══════════════════════════════════════════════════════════ + +# Points from purchase (1 point per dollar) +user.wallet(:points).credit( + order.total_cents / 100, + category: :purchase, + metadata: { order_id: order.id } +) + +# Bonus points for specific products +user.wallet(:points).credit(150, category: :bonus_item, metadata: { sku: "featured_product" }) + +# Referral bonus +user.wallet(:points).credit(500, category: :referral, metadata: { referred_user_id: friend.id }) + +# Daily check-in streaks +user.wallet(:points).credit(50 * streak_multiplier, category: :daily_checkin) + +# Receipt scanning (Ibotta-style) +user.wallet(:points).credit(100, category: :receipt_scan, metadata: { receipt_id: 123 }) + +# ═══════════════════════════════════════════════════════════ +# EXPIRING PROMOS — use-it-or-lose-it campaigns +# ═══════════════════════════════════════════════════════════ + +# Welcome bonus that expires in 30 days +user.wallet(:points).credit( + 500, + category: :welcome_bonus, + expires_at: 30.days.from_now +) + +# Double points weekend (expires Monday) +user.wallet(:points).credit( + 200, + category: :promo, + expires_at: Date.current.next_occurring(:monday), + metadata: { campaign: "double_points_weekend" } +) + +# Birthday reward +user.wallet(:points).credit( + 1000, + category: :birthday, + expires_at: 1.month.from_now, + metadata: { birthday_year: Date.current.year } +) + +# ═══════════════════════════════════════════════════════════ +# TRANSFER — gift to friends, pool with family +# ═══════════════════════════════════════════════════════════ + +# Gift points to another member +user.wallet(:points).transfer_to( + friend.wallet(:points), + 500, + category: :gift, + metadata: { message: "Happy birthday!" } +) + +# Family pooling (multiple transfers to a shared account) +family_members.each do |member| + member.wallet(:points).transfer_to( + family_pool.wallet(:points), + member.wallet(:points).balance, + category: :family_pool + ) +end + +# ═══════════════════════════════════════════════════════════ +# REDEEM — rewards, discounts, cash out +# ═══════════════════════════════════════════════════════════ -### Games and virtual economies +# Redeem for a reward +user.wallet(:points).debit( + 2500, + category: :redemption, + metadata: { reward: "free_coffee", reward_id: 42 } +) + +# Redeem for statement credit / gift card +user.wallet(:points).debit( + 10_000, + category: :cash_out, + metadata: { gift_card_code: "XXXX-YYYY", value_cents: 1000 } +) -Games often need more than one balance: +# Partial redemption with points + cash +points_portion = 500 +user.wallet(:points).debit( + points_portion, + category: :partial_redemption, + metadata: { order_id: order.id, points_value_cents: points_portion } +) +``` -- `user.wallet(:wood)` -- `user.wallet(:stone)` -- `user.wallet(:gold)` -- `user.wallet(:gems)` +**Loyalty-specific patterns:** -That maps well to strategy and farming games in the vein of OGame or FarmVille, or to games with premium and earned resources like Fortnite-style economies. +| Pattern | Implementation | +|---------|----------------| +| **Tiered earning** | `credit(amount * tier_multiplier, ...)` | +| **Points expiration** | `expires_at: 1.year.from_now` | +| **Family pooling** | `transfer_to` family wallet | +| **Gifting** | `transfer_to` friend's wallet | +| **Earn + burn in one transaction** | `debit` points, `credit` new promo points | +| **Points + cash** | `debit` points portion, charge card for remainder | -### Marketplaces +**Real-world examples this pattern fits:** -Marketplaces need more than a cached integer: +- Starbucks Stars +- Airline miles (Delta SkyMiles, United MileagePlus) +- Credit card points (Chase Ultimate Rewards, Amex MR) +- Hotel points (Marriott Bonvoy, Hilton Honors) +- Retail loyalty (Sephora Beauty Insider, REI Co-op) +- Cashback apps (Rakuten, Ibotta, Fetch) +- Fitness rewards (Sweatcoin, Stepn) -- buyer store credit -- seller earnings -- referral bonuses -- internal transfers -- auditable transaction history +### Gig economy / Driver earnings -`wallets` works well for Etsy-style, Fiverr-style, or platform-credit marketplace flows where the app is the source of truth for internal balances. +An Uber/DoorDash-style app with earnings and tips: -### Reward and gig apps +```ruby +class Driver < ApplicationRecord + has_wallets default_asset: :usd_cents +end -Many B2C apps reward users for actions: +# Ride completed +driver.wallet(:usd_cents).credit( + 1250, # $12.50 base fare + category: :ride_fare, + metadata: { ride_id: ride.id, distance_miles: 5.2 } +) -- completing rides -- referring a friend -- finishing a challenge -- scanning receipts -- daily streaks +# Tip added later +driver.wallet(:usd_cents).credit( + 300, # $3.00 tip + category: :tip, + metadata: { ride_id: ride.id, rider_id: rider.id } +) -That maps naturally to cashback apps, loyalty products, and DoorDash/Uber/Sweatcoin-style internal earning systems. +# Weekly payout +driver.wallet(:usd_cents).debit( + driver.wallet(:usd_cents).balance, + category: :weekly_payout, + metadata: { payout_date: Date.current, bank_account: "****1234" } +) +``` ## Perfect use cases -`wallets` is best for closed-loop value inside your app. +`wallets` is best for **closed-loop value** inside your app — where the app itself is the source of truth. + +| Use case | Example | Why `wallets` fits | +|----------|---------|-------------------| +| **Telecom / data plans** | Mobile data that users can share | Multi-asset (`:data_mb`, `:sms`, `:minutes`), transfers, expiration | +| **Game economies** | FarmVille, Fortnite, OGame | Multiple resources, trading between players | +| **Marketplaces** | Etsy, Fiverr, Airbnb | Seller earnings, buyer credits, platform settlements | +| **Rewards / loyalty** | Sweatcoin, credit card points | Points from actions, expiring promos, redemptions | +| **Gig economy** | Uber, DoorDash | Driver earnings, tips, scheduled payouts | +| **Multi-currency** | Travel apps, international platforms | Per-currency wallets (`:eur`, `:usd`, `:gbp`) | +| **Store credit** | Gift cards, refund credits | Simple balance with full audit trail | + +**Key signals that `wallets` is the right fit:** +- Users hold **multiple types of value** (not just one "credits" balance) +- Users **transfer value to each other** (gifts, trades, P2P payments) +- Value **expires** (promotional credits, seasonal currencies, data rollovers) +- You need a **full audit trail** (not just a cached integer) +- The app is the **source of truth** (not syncing with external ledgers) + +## When NOT to use `wallets` -Use it when value is created, tracked, spent, and transferred inside your own product, and you want something much more trustworthy than a single integer column. +### Use `usage_credits` instead if: -- In-game economies with multiple resources like `:wood`, `:stone`, `:gold`, and `:gems`. -- Marketplace internal balances like seller earnings, buyer credits, referral bonuses, and platform-managed payouts. -- Rewards, loyalty, cashback, and streak systems where users earn value from actions and redeem it later. -- Multi-asset apps where one user can hold several balances like `:eur`, `:usd`, `:credits`, or `:gems`. -- Internal peer-to-peer transfers, gifting, marketplace settlement, and in-app value movement between users. +- You're building a **SaaS/API product** with usage-based pricing +- You need **Stripe subscriptions** with automatic credit fulfillment +- You want an **operations DSL** like `spend_credits_on(:generate_report)` +- Your users **buy credits to perform specific actions** (not hold transferable balances) -It is especially strong when the app itself is the source of truth for the balance ledger. +See [usage_credits](https://github.com/rameerez/usage_credits) — it uses `wallets` underneath. -## Anti use cases +### Use something else entirely if: -`wallets` is the wrong abstraction when the hard part of the product is external money movement, regulation, or accounting-grade settlement. +`wallets` is the wrong abstraction when the hard part is external money movement, regulation, or accounting-grade settlement: -- Bank-like money infrastructure with transfers to and from bank rails, cards, ACH, or SEPA. -- Regulated stored-value products where KYC, AML, licensing, or custody are core requirements. -- Escrow and held-balance systems with pending, available, reserved, or delayed-release states. -- Multi-currency conversion systems where FX rates and conversion rules are first-class concerns. -- Full accounting engines with charts of accounts, journal entries, financial reporting, and reconciliation. -- Blockchain or crypto-style systems where consensus, custody, and cryptographic guarantees matter. -- Extremely simple apps that only need one cached counter and do not care about history, auditability, transfer records, or expirations. +- **Banking infrastructure** — transfers to/from bank rails, cards, ACH, SEPA +- **Regulated stored-value** — KYC, AML, licensing, custody requirements +- **Escrow systems** — pending, available, reserved, delayed-release states +- **FX conversion** — multi-currency conversion with exchange rates +- **Full accounting** — charts of accounts, journal entries, financial reporting +- **Blockchain/crypto** — consensus, custody, cryptographic guarantees -If the question is "how do I safely track balances and transfers inside my app?", this gem is a good fit. +### Skip both gems if: -If the question is "how do I build payments infrastructure or a banking system?", this gem is not enough by itself. +- You just need **one cached integer** (`users.balance += 1`) and don't care about history, audits, or transfers +- Your "balance" is just a counter for display purposes + +**Rule of thumb:** +- "How do I track balances and transfers inside my app?" → `wallets` +- "How do I sell credits for API/SaaS operations?" → `usage_credits` +- "How do I build payments infrastructure?" → Neither (you need a banking partner) ## Is this production-ready? @@ -344,6 +700,12 @@ What it does not do for you: So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself. +## TODO + +- First-class transfer reversal/refund API built on compensating ledger entries +- Optional pending/held balance primitives for escrow-like flows +- Multi-step transfer policies beyond `:preserve`, `:none`, and fixed `expires_at` + ## Development Run the test suite: diff --git a/lib/generators/wallets/templates/create_wallets_tables.rb.erb b/lib/generators/wallets/templates/create_wallets_tables.rb.erb index df914c8..815d5fa 100644 --- a/lib/generators/wallets/templates/create_wallets_tables.rb.erb +++ b/lib/generators/wallets/templates/create_wallets_tables.rb.erb @@ -21,6 +21,7 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %> t.string :asset_code, null: false t.bigint :amount, null: false t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps @@ -60,10 +61,6 @@ class CreateWalletsTables < ActiveRecord::Migration<%= migration_version %> add_index transactions_table, [:expires_at, :id], name: index_name("transactions_on_expires_at_and_id") add_index allocations_table, [:transaction_id, :source_transaction_id], name: index_name("allocations_on_tx_and_source_tx") add_index transfers_table, [:from_wallet_id, :to_wallet_id, :asset_code], name: index_name("transfers_on_wallets_and_asset") - - # Add these after both tables exist to avoid circular foreign key creation. - add_reference transfers_table, :outbound_transaction, type: foreign_key_type, foreign_key: { to_table: transactions_table } - add_reference transfers_table, :inbound_transaction, type: foreign_key_type, foreign_key: { to_table: transactions_table } end private diff --git a/lib/wallets/configuration.rb b/lib/wallets/configuration.rb index 02a8be0..4e34b26 100644 --- a/lib/wallets/configuration.rb +++ b/lib/wallets/configuration.rb @@ -11,6 +11,7 @@ class Configuration attr_accessor :allow_negative_balance attr_reader :default_asset, :additional_categories, :table_prefix attr_reader :low_balance_threshold + attr_reader :transfer_expiration_policy # ========================================= # Lifecycle Callbacks @@ -30,6 +31,7 @@ def initialize @additional_categories = [] @allow_negative_balance = false @low_balance_threshold = nil + @transfer_expiration_policy = :preserve # This prefix is used by the models at runtime and by the install # migration when it is executed for the first time. @table_prefix = "wallets_" @@ -71,6 +73,15 @@ def table_prefix=(value) @table_prefix = value end + def transfer_expiration_policy=(value) + normalized_value = value.to_s.strip.downcase.to_sym + allowed_values = %i[preserve none] + + raise ArgumentError, "Transfer expiration policy must be one of: #{allowed_values.join(', ')}" unless allowed_values.include?(normalized_value) + + @transfer_expiration_policy = normalized_value + end + def on_balance_credited(&block) @on_balance_credited_callback = block end diff --git a/lib/wallets/models/allocation.rb b/lib/wallets/models/allocation.rb index cae7f30..442bc98 100644 --- a/lib/wallets/models/allocation.rb +++ b/lib/wallets/models/allocation.rb @@ -4,9 +4,20 @@ module Wallets # Allocations link a negative spend transaction to the positive transactions it # consumed from. This is what makes FIFO spending and expiration-aware balances # possible without mutating historical transactions. + # + # This class supports embedding: subclasses can override config and table + # names without affecting the base Wallets::* behavior. class Allocation < ApplicationRecord + class_attribute :embedded_table_name, default: nil + class_attribute :config_provider, default: -> { Wallets.configuration } + def self.table_name - "#{Wallets.configuration.table_prefix}allocations" + embedded_table_name || "#{resolved_config.table_prefix}allocations" + end + + def self.resolved_config + value = config_provider + value.respond_to?(:call) ? value.call : value end belongs_to :spend_transaction, class_name: "Wallets::Transaction", foreign_key: "transaction_id" diff --git a/lib/wallets/models/transaction.rb b/lib/wallets/models/transaction.rb index 41940a6..4f16dfd 100644 --- a/lib/wallets/models/transaction.rb +++ b/lib/wallets/models/transaction.rb @@ -4,9 +4,20 @@ module Wallets # Transactions are the append-only source of truth for wallet balance changes. # Positive rows add value, negative rows consume value, and transfers link both # sides of an internal movement through `transfer_id`. + # + # This class supports embedding: subclasses can override config and table + # names without affecting the base Wallets::* behavior. class Transaction < ApplicationRecord + class_attribute :embedded_table_name, default: nil + class_attribute :config_provider, default: -> { Wallets.configuration } + def self.table_name - "#{Wallets.configuration.table_prefix}transactions" + embedded_table_name || "#{resolved_config.table_prefix}transactions" + end + + def self.resolved_config + value = config_provider + value.respond_to?(:call) ? value.call : value end DEFAULT_CATEGORIES = [ @@ -22,6 +33,17 @@ def self.table_name ].freeze CATEGORIES = DEFAULT_CATEGORIES + def self.categories + extra_categories = + if resolved_config.respond_to?(:additional_categories) + resolved_config.additional_categories + else + [] + end + + (DEFAULT_CATEGORIES + extra_categories).uniq + end + belongs_to :wallet, class_name: "Wallets::Wallet" belongs_to :transfer, class_name: "Wallets::Transfer", optional: true @@ -36,7 +58,7 @@ def self.table_name dependent: :destroy validates :amount, presence: true, numericality: { only_integer: true } - validates :category, presence: true, inclusion: { in: ->(_) { categories } } + validates :category, presence: true, inclusion: { in: ->(record) { record.class.categories } } validate :remaining_amount_cannot_be_negative before_save :sync_metadata_cache @@ -48,10 +70,6 @@ def self.table_name scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } scope :expired, -> { where("expires_at < ?", Time.current) } - def self.categories - (DEFAULT_CATEGORIES + Wallets.configuration.additional_categories).uniq - end - def metadata @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) end @@ -96,8 +114,6 @@ def remaining_amount amount - allocated_amount end - # When negative balances are allowed, a debit can exceed the currently - # available positive buckets. The unmatched portion remains "unbacked". def unbacked_amount return 0 unless debit? diff --git a/lib/wallets/models/transfer.rb b/lib/wallets/models/transfer.rb index 9f43a70..1f56917 100644 --- a/lib/wallets/models/transfer.rb +++ b/lib/wallets/models/transfer.rb @@ -4,23 +4,46 @@ module Wallets # A transfer records an internal movement of value between two wallets of the # same asset. The actual balance impact lives in the linked transactions on # each side so the ledger remains append-only. + # + # Transfers keep the outbound leg singular and the inbound legs plural so the + # receiver can preserve the sender's expiration buckets when one transfer + # consumes multiple source transactions with different expirations. class Transfer < ApplicationRecord + class_attribute :embedded_table_name, default: nil + class_attribute :config_provider, default: -> { Wallets.configuration } + class_attribute :transaction_class_name, default: "Wallets::Transaction" + + SUPPORTED_EXPIRATION_POLICIES = %w[preserve none fixed].freeze + def self.table_name - "#{Wallets.configuration.table_prefix}transfers" + embedded_table_name || "#{resolved_config.table_prefix}transfers" + end + + def self.resolved_config + value = config_provider + value.respond_to?(:call) ? value.call : value + end + + def self.transaction_class + transaction_class_name.constantize end belongs_to :from_wallet, class_name: "Wallets::Wallet", inverse_of: :outgoing_transfers belongs_to :to_wallet, class_name: "Wallets::Wallet", inverse_of: :incoming_transfers - belongs_to :outbound_transaction, class_name: "Wallets::Transaction", optional: true - belongs_to :inbound_transaction, class_name: "Wallets::Transaction", optional: true + + has_many :transactions, + class_name: "Wallets::Transaction", + foreign_key: :transfer_id, + inverse_of: :transfer validates :asset_code, presence: true validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :expiration_policy, presence: true, inclusion: { in: SUPPORTED_EXPIRATION_POLICIES } validate :wallets_must_differ validate :wallet_assets_match_transfer_asset - validate :linked_transactions_match_wallets before_validation :normalize_asset_code! + before_validation :normalize_expiration_policy! before_save :sync_metadata_cache def metadata @@ -37,12 +60,37 @@ def reload(*) super end + def outbound_transactions + transfer_transactions_for(wallet_id: from_wallet_id).where("amount < 0") + end + + def outbound_transaction + outbound_transactions.order(:id).first + end + + def inbound_transactions + transfer_transactions_for(wallet_id: to_wallet_id).where("amount > 0") + end + + def inbound_transaction + records = inbound_transactions.order(:id).limit(2).to_a + records.one? ? records.first : nil + end + private + def transaction_class + self.class.transaction_class + end + def normalize_asset_code! self.asset_code = asset_code.to_s.strip.downcase.presence end + def normalize_expiration_policy! + self.expiration_policy = expiration_policy.to_s.strip.downcase.presence + end + def wallets_must_differ return if from_wallet.blank? || to_wallet.blank? return if from_wallet_id != to_wallet_id @@ -57,16 +105,6 @@ def wallet_assets_match_transfer_asset errors.add(:asset_code, "must match both wallets") end - def linked_transactions_match_wallets - if outbound_transaction.present? && from_wallet.present? && outbound_transaction.wallet_id != from_wallet_id - errors.add(:outbound_transaction, "must belong to the source wallet") - end - - if inbound_transaction.present? && to_wallet.present? && inbound_transaction.wallet_id != to_wallet_id - errors.add(:inbound_transaction, "must belong to the target wallet") - end - end - def sync_metadata_cache if @indifferent_metadata write_attribute(:metadata, @indifferent_metadata.to_h) @@ -74,5 +112,11 @@ def sync_metadata_cache write_attribute(:metadata, {}) end end + + def transfer_transactions_for(wallet_id:) + return transaction_class.none unless persisted? && wallet_id.present? + + transaction_class.where(transfer_id: id, wallet_id: wallet_id) + end end end diff --git a/lib/wallets/models/wallet.rb b/lib/wallets/models/wallet.rb index 7134a4c..99481fa 100644 --- a/lib/wallets/models/wallet.rb +++ b/lib/wallets/models/wallet.rb @@ -7,11 +7,59 @@ module Wallets # wallet derives its current balance from transactions and allocations so it # can support FIFO consumption, expirations, transfers, and a durable audit # trail at the same time. + # + # This class supports embedding: subclasses can override config, callbacks, + # table names, and related model classes without affecting the base Wallets::* + # behavior in the same application. class Wallet < ApplicationRecord + # ========================================= + # Embeddability Hooks + # ========================================= + + class_attribute :embedded_table_name, default: nil + class_attribute :config_provider, default: -> { Wallets.configuration } + class_attribute :callbacks_module, default: Wallets::Callbacks + class_attribute :transaction_class_name, default: "Wallets::Transaction" + class_attribute :allocation_class_name, default: "Wallets::Allocation" + class_attribute :transfer_class_name, default: "Wallets::Transfer" + class_attribute :callback_event_map, default: { + credited: :balance_credited, + debited: :balance_debited, + insufficient: :insufficient_balance, + low_balance: :low_balance_reached, + depleted: :balance_depleted, + transfer_completed: :transfer_completed + }.freeze + + # ========================================= + # Table Name Resolution + # ========================================= + def self.table_name - "#{Wallets.configuration.table_prefix}wallets" + embedded_table_name || "#{resolved_config.table_prefix}wallets" + end + + def self.resolved_config + value = config_provider + value.respond_to?(:call) ? value.call : value + end + + def self.transaction_class + transaction_class_name.constantize end + def self.allocation_class + allocation_class_name.constantize + end + + def self.transfer_class + transfer_class_name.constantize + end + + # ========================================= + # Associations & Validations + # ========================================= + belongs_to :owner, polymorphic: true has_many :transactions, class_name: "Wallets::Transaction", dependent: :destroy @@ -33,6 +81,10 @@ def self.table_name before_validation :normalize_asset_code! before_save :sync_metadata_cache + # ========================================= + # Class Methods + # ========================================= + class << self def create_for_owner!(owner:, asset_code:, initial_balance: 0, metadata: {}) initial_balance = normalize_initial_balance(initial_balance) @@ -71,6 +123,10 @@ def normalize_initial_balance(value) end end + # ========================================= + # Metadata Handling + # ========================================= + def metadata @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {}) end @@ -85,13 +141,15 @@ def reload(*) super end + # ========================================= + # Balance & History + # ========================================= + def balance current_balance end def current_balance - # Available balance is the unspent, unexpired value minus any debit that - # was allowed to go negative and therefore could not be backed yet. positive_remaining_balance - unbacked_negative_balance end @@ -106,7 +164,11 @@ def has_enough_balance?(amount) false end - def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil) + # ========================================= + # Credit & Debit Operations + # ========================================= + + def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, **extra_transaction_attributes) metadata = normalize_metadata(metadata) with_lock do @@ -115,12 +177,13 @@ def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: n metadata: metadata, category: category, expires_at: expires_at, - transfer: transfer + transfer: transfer, + extra_attributes: extra_transaction_attributes ) end end - def debit(amount, metadata: {}, category: :debit, transfer: nil) + def debit(amount, metadata: {}, category: :debit, transfer: nil, **extra_transaction_attributes) metadata = normalize_metadata(metadata) with_lock do @@ -128,68 +191,86 @@ def debit(amount, metadata: {}, category: :debit, transfer: nil) amount, metadata: metadata, category: category, - transfer: transfer + transfer: transfer, + extra_attributes: extra_transaction_attributes ) end end - def transfer_to(other_wallet, amount, category: :transfer, metadata: {}) + # ========================================= + # Transfers + # ========================================= + + def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil) raise InvalidTransfer, "Target wallet is required" if other_wallet.nil? raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted? raise InvalidTransfer, "Cannot transfer to the same wallet" if other_wallet.id == id raise InvalidTransfer, "Wallet assets must match" unless asset_code == other_wallet.asset_code + raise InvalidTransfer, "Wallet classes must match" unless other_wallet.class == self.class amount = normalize_positive_amount!(amount) metadata = normalize_metadata(metadata) + resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at) ActiveRecord::Base.transaction do lock_wallet_pair!(other_wallet) - transfer = Wallets::Transfer.create!( + previous_balance = balance + if amount > previous_balance + dispatch_insufficient_balance!(amount, previous_balance, metadata) + raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})" + end + + transfer = transfer_class.create!( from_wallet: self, to_wallet: other_wallet, asset_code: asset_code, amount: amount, category: category, + expiration_policy: resolved_policy, metadata: metadata ) shared_metadata = metadata.to_h.deep_stringify_keys.merge( "transfer_id" => transfer.id, "asset_code" => asset_code, - "counterparty_wallet_id" => other_wallet.id, - "counterparty_owner_id" => other_wallet.owner_id, - "counterparty_owner_type" => other_wallet.owner_type + "transfer_category" => category.to_s, + "transfer_expiration_policy" => resolved_policy ) outbound_transaction = apply_debit( amount, category: :transfer_out, - metadata: shared_metadata.merge("transfer_category" => category.to_s), - transfer: transfer - ) - - inbound_transaction = other_wallet.send( - :apply_credit, - amount, - category: :transfer_in, metadata: shared_metadata.merge( - "counterparty_wallet_id" => id, - "counterparty_owner_id" => owner_id, - "counterparty_owner_type" => owner_type, - "transfer_category" => category.to_s + "counterparty_wallet_id" => other_wallet.id, + "counterparty_owner_id" => other_wallet.owner_id, + "counterparty_owner_type" => other_wallet.owner_type ), - expires_at: nil, transfer: transfer ) - transfer.update!( + build_transfer_inbound_credit_specs( + transfer: transfer, outbound_transaction: outbound_transaction, - inbound_transaction: inbound_transaction - ) + amount: amount, + expiration_policy: resolved_policy, + expires_at: inbound_expires_at + ).each do |spec| + other_wallet.send( + :apply_credit, + spec[:amount], + category: :transfer_in, + metadata: shared_metadata.merge( + "counterparty_wallet_id" => id, + "counterparty_owner_id" => owner_id, + "counterparty_owner_type" => owner_type + ), + expires_at: spec[:expires_at], + transfer: transfer + ) + end - Wallets::Callbacks.dispatch( - :transfer_completed, + dispatch_callback(:transfer_completed, wallet: self, transfer: transfer, amount: amount, @@ -203,25 +284,65 @@ def transfer_to(other_wallet, amount, category: :transfer, metadata: {}) private - def apply_credit(amount, metadata:, category:, expires_at:, transfer:) + # ========================================= + # Config & Class Accessors (Instance) + # ========================================= + + def config + self.class.resolved_config + end + + def callbacks + self.class.callbacks_module + end + + def transaction_class + self.class.transaction_class + end + + def allocation_class + self.class.allocation_class + end + + def transfer_class + self.class.transfer_class + end + + # ========================================= + # Callback Dispatching + # ========================================= + + def dispatch_callback(kind, **data) + event = self.class.callback_event_map[kind] + return if event.nil? + + callbacks.dispatch(event, **data) + end + + # ========================================= + # Credit/Debit Implementation + # ========================================= + + def apply_credit(amount, metadata:, category:, expires_at:, transfer:, extra_attributes: {}) amount = normalize_positive_amount!(amount) validate_expiration!(expires_at) previous_balance = balance transaction = transactions.create!( - amount: amount, - category: category, - expires_at: expires_at, - metadata: metadata, - transfer: transfer + { + amount: amount, + category: category, + expires_at: expires_at, + metadata: metadata, + transfer: transfer + }.merge(extra_attributes) ) refresh_cached_balance! transaction.sync_balance_snapshot!(before: previous_balance, after: balance) - Wallets::Callbacks.dispatch( - :balance_credited, + dispatch_callback(:credited, wallet: self, amount: amount, category: category, @@ -234,7 +355,7 @@ def apply_credit(amount, metadata:, category:, expires_at:, transfer:) transaction end - def apply_debit(amount, metadata:, category:, transfer:) + def apply_debit(amount, metadata:, category:, transfer:, extra_attributes: {}) amount = normalize_positive_amount!(amount) previous_balance = balance @@ -244,10 +365,12 @@ def apply_debit(amount, metadata:, category:, transfer:) end spend_transaction = transactions.create!( - amount: -amount, - category: category, - metadata: metadata, - transfer: transfer + { + amount: -amount, + category: category, + metadata: metadata, + transfer: transfer + }.merge(extra_attributes) ) remaining_to_allocate = allocate_debit!(spend_transaction, amount) @@ -259,8 +382,7 @@ def apply_debit(amount, metadata:, category:, transfer:) refresh_cached_balance! spend_transaction.sync_balance_snapshot!(before: previous_balance, after: balance) - Wallets::Callbacks.dispatch( - :balance_debited, + dispatch_callback(:debited, wallet: self, amount: amount, category: category, @@ -278,8 +400,6 @@ def apply_debit(amount, metadata:, category:, transfer:) def allocate_debit!(spend_transaction, amount) remaining_to_allocate = amount - # Spend the oldest available buckets first so expiring value is consumed - # before evergreen value when both are present. positive_transactions = transactions .where("amount > 0") .where("expires_at IS NULL OR expires_at > ?", Time.current) @@ -293,7 +413,7 @@ def allocate_debit!(spend_transaction, amount) allocation_amount = [leftover, remaining_to_allocate].min - Allocation.create!( + allocation_class.create!( spend_transaction: spend_transaction, source_transaction: source_transaction, amount: allocation_amount @@ -306,28 +426,100 @@ def allocate_debit!(spend_transaction, amount) remaining_to_allocate end + # ========================================= + # Transfer Expiration Handling + # ========================================= + + def resolve_transfer_expiration!(expiration_policy, expires_at) + default_policy = + if config.respond_to?(:transfer_expiration_policy) + config.transfer_expiration_policy + else + :preserve + end + + policy = + if expires_at.present? && expiration_policy.nil? + "fixed" + else + normalize_transfer_expiration_policy(expiration_policy || default_policy) + end + + case policy + when "preserve", "none" + raise ArgumentError, "expires_at cannot be combined with #{policy} transfer expiration policy" if expires_at.present? + [policy, nil] + when "fixed" + raise ArgumentError, "expires_at is required when using a fixed transfer expiration policy" if expires_at.nil? + + validate_expiration!(expires_at) + [policy, expires_at] + else + raise ArgumentError, "Unsupported transfer expiration policy: #{policy}" + end + end + + def normalize_transfer_expiration_policy(value) + value.to_s.strip.downcase + end + + def build_transfer_inbound_credit_specs(transfer:, outbound_transaction:, amount:, expiration_policy:, expires_at:) + case expiration_policy + when "none" + [{ amount: amount, expires_at: nil }] + when "fixed" + [{ amount: amount, expires_at: expires_at }] + when "preserve" + build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount) + else + raise ArgumentError, "Unsupported transfer expiration policy: #{expiration_policy}" + end + end + + def build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount) + allocations = outbound_transaction.outgoing_allocations.includes(:source_transaction).order(:id).to_a + grouped_specs = [] + + allocations.each do |allocation| + expires_at = allocation.source_transaction.expires_at + + if grouped_specs.last && grouped_specs.last[:expires_at] == expires_at + grouped_specs.last[:amount] += allocation.amount + else + grouped_specs << { amount: allocation.amount, expires_at: expires_at } + end + end + + total_preserved_amount = grouped_specs.sum { |spec| spec[:amount] } + if total_preserved_amount != amount + raise InvalidTransfer, "Transfer #{transfer.id} could not preserve expiration buckets (#{total_preserved_amount} != #{amount})" + end + + grouped_specs + end + + # ========================================= + # Balance Calculation + # ========================================= + def positive_remaining_balance - transactions_table = Wallets::Transaction.table_name - allocations_table = Wallets::Allocation.table_name + txn_table = transaction_class.table_name + alloc_table = allocation_class.table_name - # Summing remaining positive buckets is more accurate than summing raw - # transaction amounts once expirations and partial consumption exist. transactions .where("amount > 0") .where("expires_at IS NULL OR expires_at > ?", Time.current) - .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM #{allocations_table} WHERE source_transaction_id = #{transactions_table}.id)") + .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE source_transaction_id = #{txn_table}.id)") .to_i end def unbacked_negative_balance - transactions_table = Wallets::Transaction.table_name - allocations_table = Wallets::Allocation.table_name + txn_table = transaction_class.table_name + alloc_table = allocation_class.table_name - # Negative balances are represented as debits whose full amount could not - # be matched to positive source buckets at the time of spending. transactions .where("amount < 0") - .sum("ABS(amount) - (SELECT COALESCE(SUM(amount), 0) FROM #{allocations_table} WHERE transaction_id = #{transactions_table}.id)") + .sum("ABS(amount) - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE transaction_id = #{txn_table}.id)") .to_i end @@ -336,9 +528,12 @@ def refresh_cached_balance! save! end + # ========================================= + # Threshold Callbacks + # ========================================= + def dispatch_insufficient_balance!(amount, previous_balance, metadata) - Wallets::Callbacks.dispatch( - :insufficient_balance, + dispatch_callback(:insufficient, wallet: self, amount: amount, previous_balance: previous_balance, @@ -352,18 +547,16 @@ def dispatch_insufficient_balance!(amount, previous_balance, metadata) def dispatch_balance_threshold_callbacks!(previous_balance) if !was_low_balance?(previous_balance) && low_balance? - Wallets::Callbacks.dispatch( - :low_balance_reached, + dispatch_callback(:low_balance, wallet: self, - threshold: Wallets.configuration.low_balance_threshold, + threshold: config.low_balance_threshold, previous_balance: previous_balance, new_balance: balance ) end if previous_balance.positive? && balance.zero? - Wallets::Callbacks.dispatch( - :balance_depleted, + dispatch_callback(:depleted, wallet: self, previous_balance: previous_balance, new_balance: 0 @@ -372,23 +565,27 @@ def dispatch_balance_threshold_callbacks!(previous_balance) end def low_balance? - threshold = Wallets.configuration.low_balance_threshold + threshold = config.low_balance_threshold return false if threshold.nil? balance <= threshold end def was_low_balance?(previous_balance) - threshold = Wallets.configuration.low_balance_threshold + threshold = config.low_balance_threshold return false if threshold.nil? previous_balance <= threshold end def allow_negative_balance? - Wallets.configuration.allow_negative_balance + config.allow_negative_balance end + # ========================================= + # Validation Helpers + # ========================================= + def validate_expiration!(expires_at) return if expires_at.nil? raise ArgumentError, "Expiration date must respond to to_datetime" unless expires_at.respond_to?(:to_datetime) @@ -410,7 +607,6 @@ def normalize_metadata(metadata) end def lock_wallet_pair!(other_wallet) - # Lock in a stable order so concurrent transfers do not deadlock. first, second = [self, other_wallet].sort_by(&:id) first.lock! second.lock! unless first.id == second.id diff --git a/lib/wallets/version.rb b/lib/wallets/version.rb index 96773c0..93bad3a 100644 --- a/lib/wallets/version.rb +++ b/lib/wallets/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Wallets - VERSION = "0.1.0" + VERSION = "0.2.0" end diff --git a/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb b/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb index f07404b..f69989f 100644 --- a/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb +++ b/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb @@ -18,6 +18,7 @@ def change t.string :asset_code, null: false t.bigint :amount, null: false t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" t.json :metadata, null: false, default: {} t.timestamps end @@ -39,9 +40,6 @@ def change t.timestamps end - add_reference :wallets_transfers, :outbound_transaction, foreign_key: { to_table: :wallets_transactions } - add_reference :wallets_transfers, :inbound_transaction, foreign_key: { to_table: :wallets_transactions } - add_index :wallets_transactions, :category add_index :wallets_transactions, :expires_at add_index :wallets_transactions, [:wallet_id, :amount], name: "index_wallet_transactions_on_wallet_id_and_amount" diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 793f39d..c0025d6 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -51,15 +51,12 @@ t.string "asset_code", null: false t.string "category", default: "transfer", null: false t.datetime "created_at", null: false + t.string "expiration_policy", default: "preserve", null: false t.integer "from_wallet_id", null: false - t.integer "inbound_transaction_id" t.json "metadata", default: {}, null: false - t.integer "outbound_transaction_id" t.integer "to_wallet_id", null: false t.datetime "updated_at", null: false t.index ["from_wallet_id"], name: "index_wallets_transfers_on_from_wallet_id" - t.index ["inbound_transaction_id"], name: "index_wallets_transfers_on_inbound_transaction_id" - t.index ["outbound_transaction_id"], name: "index_wallets_transfers_on_outbound_transaction_id" t.index ["to_wallet_id"], name: "index_wallets_transfers_on_to_wallet_id" end @@ -79,8 +76,6 @@ add_foreign_key "wallets_allocations", "wallets_transactions", column: "transaction_id" add_foreign_key "wallets_transactions", "wallets_transfers", column: "transfer_id" add_foreign_key "wallets_transactions", "wallets_wallets", column: "wallet_id" - add_foreign_key "wallets_transfers", "wallets_transactions", column: "inbound_transaction_id" - add_foreign_key "wallets_transfers", "wallets_transactions", column: "outbound_transaction_id" add_foreign_key "wallets_transfers", "wallets_wallets", column: "from_wallet_id" add_foreign_key "wallets_transfers", "wallets_wallets", column: "to_wallet_id" end diff --git a/test/integration/embeddability_test.rb b/test/integration/embeddability_test.rb new file mode 100644 index 0000000..eca9a8d --- /dev/null +++ b/test/integration/embeddability_test.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +require "test_helper" + +# These tests verify that wallets can be embedded in another gem (like +# usage_credits) without global config collisions. The embedded classes use +# custom config, callbacks, table names, and model classes. Unlike simpler unit +# checks, these tests use real embedded tables and re-declared associations so +# we can prove the runtime behavior that embedded consumers rely on. +class EmbeddabilityTest < ActiveSupport::TestCase + class EmbeddedConfig + attr_accessor :allow_negative_balance + attr_reader :table_prefix, :low_balance_threshold, :additional_categories, :transfer_expiration_policy + + def initialize + @table_prefix = "embedded_" + @allow_negative_balance = false + @low_balance_threshold = 50 + @additional_categories = %w[embedded_reward embedded_charge] + @transfer_expiration_policy = :preserve + end + + def low_balance_threshold=(value) + @low_balance_threshold = value + end + + def additional_categories=(value) + @additional_categories = value + end + + def transfer_expiration_policy=(value) + @transfer_expiration_policy = value.to_sym + end + end + + module EmbeddedCallbacks + @events = [] + + class << self + attr_accessor :events + + def dispatch(event, **data) + @events << { event: event, data: data } + end + + def reset! + @events = [] + end + end + end + + class << self + attr_writer :embedded_config + + def embedded_config + @embedded_config ||= EmbeddedConfig.new + end + + def reset_embedded_config! + self.embedded_config = EmbeddedConfig.new + end + + def ensure_embedded_tables! + return if embedded_tables_ready? + + rebuild_embedded_tables! + end + + private + + def embedded_tables_ready? + connection = ActiveRecord::Base.connection + connection.data_source_exists?("embedded_wallets") && + connection.data_source_exists?("embedded_transactions") && + connection.data_source_exists?("embedded_transfers") && + connection.data_source_exists?("embedded_allocations") && + connection.column_exists?(:embedded_transfers, :expiration_policy) && + connection.column_exists?(:embedded_transactions, :source_reference) + end + + def rebuild_embedded_tables! + connection = ActiveRecord::Base.connection + + %i[embedded_allocations embedded_transactions embedded_transfers embedded_wallets].each do |table_name| + connection.drop_table(table_name, if_exists: true) + end + + connection.create_table :embedded_wallets do |t| + t.references :owner, polymorphic: true, null: false + t.string :asset_code, null: false + t.bigint :balance, null: false, default: 0 + t.public_send(json_column_type(connection), :metadata, null: false, default: json_column_default(connection)) + t.timestamps + end + connection.add_index :embedded_wallets, [:owner_type, :owner_id, :asset_code], unique: true, name: "index_embedded_wallets_on_owner_and_asset_code" + + connection.create_table :embedded_transfers do |t| + t.integer :from_wallet_id, null: false + t.integer :to_wallet_id, null: false + t.string :asset_code, null: false + t.bigint :amount, null: false + t.string :category, null: false, default: "transfer" + t.string :expiration_policy, null: false, default: "preserve" + t.public_send(json_column_type(connection), :metadata, null: false, default: json_column_default(connection)) + t.timestamps + end + + connection.create_table :embedded_transactions do |t| + t.integer :wallet_id, null: false + t.bigint :amount, null: false + t.string :category, null: false + t.datetime :expires_at + t.integer :transfer_id + t.string :source_reference + t.public_send(json_column_type(connection), :metadata, null: false, default: json_column_default(connection)) + t.timestamps + end + + connection.create_table :embedded_allocations do |t| + t.integer :transaction_id, null: false + t.integer :source_transaction_id, null: false + t.bigint :amount, null: false + t.timestamps + end + + [EmbeddedWallet, EmbeddedTransaction, EmbeddedTransfer, EmbeddedAllocation].each(&:reset_column_information) + end + + def json_column_type(connection) + connection.adapter_name.downcase.include?("postgresql") ? :jsonb : :json + end + + def json_column_default(connection) + connection.adapter_name.downcase.include?("mysql") ? nil : {} + end + end + + class EmbeddedWallet < Wallets::Wallet + self.embedded_table_name = "embedded_wallets" + self.config_provider = -> { EmbeddabilityTest.embedded_config } + self.callbacks_module = EmbeddabilityTest::EmbeddedCallbacks + self.transaction_class_name = "EmbeddabilityTest::EmbeddedTransaction" + self.allocation_class_name = "EmbeddabilityTest::EmbeddedAllocation" + self.transfer_class_name = "EmbeddabilityTest::EmbeddedTransfer" + self.callback_event_map = { + credited: :embedded_credited, + debited: :embedded_debited, + insufficient: :embedded_insufficient, + low_balance: :embedded_low_balance, + depleted: :embedded_depleted, + transfer_completed: :embedded_transfer_completed + }.freeze + + has_many :transactions, + class_name: "EmbeddabilityTest::EmbeddedTransaction", + foreign_key: :wallet_id, + dependent: :destroy, + inverse_of: :wallet + has_many :outgoing_transfers, + class_name: "EmbeddabilityTest::EmbeddedTransfer", + foreign_key: :from_wallet_id, + dependent: :destroy, + inverse_of: :from_wallet + has_many :incoming_transfers, + class_name: "EmbeddabilityTest::EmbeddedTransfer", + foreign_key: :to_wallet_id, + dependent: :destroy, + inverse_of: :to_wallet + end + + class EmbeddedTransaction < Wallets::Transaction + self.embedded_table_name = "embedded_transactions" + self.config_provider = -> { EmbeddabilityTest.embedded_config } + + belongs_to :wallet, class_name: "EmbeddabilityTest::EmbeddedWallet", inverse_of: :transactions + belongs_to :transfer, class_name: "EmbeddabilityTest::EmbeddedTransfer", optional: true, inverse_of: :transactions + + has_many :outgoing_allocations, + class_name: "EmbeddabilityTest::EmbeddedAllocation", + foreign_key: :transaction_id, + dependent: :destroy, + inverse_of: :spend_transaction + has_many :incoming_allocations, + class_name: "EmbeddabilityTest::EmbeddedAllocation", + foreign_key: :source_transaction_id, + dependent: :destroy, + inverse_of: :source_transaction + end + + class EmbeddedAllocation < Wallets::Allocation + self.embedded_table_name = "embedded_allocations" + self.config_provider = -> { EmbeddabilityTest.embedded_config } + + belongs_to :spend_transaction, + class_name: "EmbeddabilityTest::EmbeddedTransaction", + foreign_key: :transaction_id, + inverse_of: :outgoing_allocations + belongs_to :source_transaction, + class_name: "EmbeddabilityTest::EmbeddedTransaction", + foreign_key: :source_transaction_id, + inverse_of: :incoming_allocations + end + + class EmbeddedTransfer < Wallets::Transfer + self.embedded_table_name = "embedded_transfers" + self.config_provider = -> { EmbeddabilityTest.embedded_config } + self.transaction_class_name = "EmbeddabilityTest::EmbeddedTransaction" + + belongs_to :from_wallet, class_name: "EmbeddabilityTest::EmbeddedWallet", inverse_of: :outgoing_transfers + belongs_to :to_wallet, class_name: "EmbeddabilityTest::EmbeddedWallet", inverse_of: :incoming_transfers + has_many :transactions, + class_name: "EmbeddabilityTest::EmbeddedTransaction", + foreign_key: :transfer_id, + inverse_of: :transfer + end + + ensure_embedded_tables! + + setup do + EmbeddabilityTest.reset_embedded_config! + EmbeddedCallbacks.reset! + cleanup_embedded_records! + end + + test "embedded classes use custom config provider" do + config = EmbeddedWallet.resolved_config + + assert_equal "embedded_", config.table_prefix + assert_equal 50, config.low_balance_threshold + assert_equal :preserve, config.transfer_expiration_policy + assert_includes config.additional_categories, "embedded_reward" + end + + test "embedded classes use custom callback module" do + assert_equal EmbeddedCallbacks, EmbeddedWallet.callbacks_module + assert_equal Wallets::Callbacks, Wallets::Wallet.callbacks_module + end + + test "embedded table names are used at runtime" do + wallet = nil + + assert_difference -> { EmbeddedWallet.count }, 1 do + assert_difference -> { EmbeddedTransaction.count }, 1 do + assert_no_difference -> { Wallets::Wallet.count } do + assert_no_difference -> { Wallets::Transaction.count } do + wallet = EmbeddedWallet.create_for_owner!( + owner: users(:new_user), + asset_code: :embedded_points, + initial_balance: 25 + ) + end + end + end + end + + assert_equal "embedded_wallets", EmbeddedWallet.table_name + assert_equal "embedded_transactions", EmbeddedTransaction.table_name + assert_equal "embedded_transfers", EmbeddedTransfer.table_name + assert_equal 1, EmbeddedWallet.where(owner: users(:new_user), asset_code: "embedded_points").count + assert_equal 1, EmbeddedTransaction.where(wallet_id: wallet.id).count + assert_nil Wallets::Wallet.find_by(owner: users(:new_user), asset_code: "embedded_points") + end + + test "embedded wallet operations dispatch to the embedded callback module" do + wallet = EmbeddedWallet.create_for_owner!( + owner: users(:new_user), + asset_code: :callback_test, + initial_balance: 0 + ) + + wallet.credit(100, category: :embedded_reward, metadata: { source: "quest" }) + + assert_equal 1, EmbeddedCallbacks.events.size + assert_equal :embedded_credited, EmbeddedCallbacks.events.first[:event] + assert_equal 100, EmbeddedCallbacks.events.first[:data][:amount] + assert_instance_of EmbeddedWallet, EmbeddedCallbacks.events.first[:data][:wallet] + assert_equal "quest", EmbeddedCallbacks.events.first[:data][:metadata][:source] + end + + test "embedded transfers create embedded records only" do + sender = EmbeddedWallet.create_for_owner!(owner: users(:rich_user), asset_code: :transfer_test, initial_balance: 100) + recipient = EmbeddedWallet.create_for_owner!(owner: users(:peer_user), asset_code: :transfer_test, initial_balance: 0) + + assert_difference -> { EmbeddedTransfer.count }, 1 do + assert_difference -> { EmbeddedTransaction.count }, 2 do + assert_no_difference -> { Wallets::Transfer.count } do + assert_no_difference -> { Wallets::Transaction.count } do + transfer = sender.transfer_to(recipient, 10, category: :peer_payment, metadata: { source: "embedded" }) + + assert_instance_of EmbeddedTransfer, transfer + assert_instance_of EmbeddedTransaction, transfer.outbound_transaction + assert_equal [EmbeddedTransaction], transfer.inbound_transactions.map(&:class).uniq + assert_equal 1, transfer.inbound_transactions.count + assert_equal 90, sender.reload.balance + assert_equal 10, recipient.reload.balance + assert_equal "embedded", transfer.metadata["source"] + assert_equal "preserve", transfer.expiration_policy + end + end + end + end + end + + test "cross-class transfers are rejected with real embedded wallets" do + base_wallet = create_wallet(users(:rich_user), asset_code: :shared_asset, initial_balance: 100) + embedded_wallet = EmbeddedWallet.create_for_owner!(owner: users(:peer_user), asset_code: :shared_asset, initial_balance: 50) + + error = assert_raises(Wallets::InvalidTransfer) do + base_wallet.transfer_to(embedded_wallet, 10, category: :peer_payment) + end + assert_equal "Wallet classes must match", error.message + + error = assert_raises(Wallets::InvalidTransfer) do + embedded_wallet.transfer_to(base_wallet, 10, category: :peer_payment) + end + assert_equal "Wallet classes must match", error.message + end + + test "credit accepts and persists extra transaction attributes on embedded subclasses" do + wallet = EmbeddedWallet.create_for_owner!(owner: users(:new_user), asset_code: :extra_attrs, initial_balance: 0) + + transaction = wallet.credit( + 100, + category: :embedded_reward, + metadata: { source: "test", custom_ref: "abc123" }, + source_reference: "FULFILLMENT-123" + ) + + assert transaction.persisted? + assert_equal 100, transaction.amount + assert_equal "test", transaction.metadata["source"] + assert_equal "abc123", transaction.metadata["custom_ref"] + assert_equal "FULFILLMENT-123", transaction.source_reference + assert_equal "FULFILLMENT-123", EmbeddedTransaction.find(transaction.id).source_reference + end + + test "debit accepts and persists extra transaction attributes on embedded subclasses" do + wallet = EmbeddedWallet.create_for_owner!(owner: users(:new_user), asset_code: :extra_attrs_debit, initial_balance: 100) + + transaction = wallet.debit( + 50, + category: :embedded_charge, + metadata: { item: "sword", order_id: 42 }, + source_reference: "ORDER-42" + ) + + assert transaction.persisted? + assert_equal(-50, transaction.amount) + assert_equal "sword", transaction.metadata["item"] + assert_equal 42, transaction.metadata["order_id"] + assert_equal "ORDER-42", transaction.source_reference + end + + private + + def cleanup_embedded_records! + [EmbeddedAllocation, EmbeddedTransaction, EmbeddedTransfer, EmbeddedWallet].each do |model| + next unless model.table_exists? + + model.delete_all + end + end +end diff --git a/test/models/wallets/transfer_test.rb b/test/models/wallets/transfer_test.rb index fe75a93..9325f9b 100644 --- a/test/models/wallets/transfer_test.rb +++ b/test/models/wallets/transfer_test.rb @@ -4,8 +4,11 @@ class Wallets::TransferTest < ActiveSupport::TestCase test "transfers value between wallets of the same asset" do - source_wallet = wallets_wallets(:rich_coins_wallet) - target_wallet = wallets_wallets(:peer_coins_wallet) + sender = User.create!(email: "transfer-sender-#{SecureRandom.hex(4)}@example.com", name: "Transfer Sender") + recipient = User.create!(email: "transfer-recipient-#{SecureRandom.hex(4)}@example.com", name: "Transfer Recipient") + source_wallet = sender.wallet(:coins) + target_wallet = recipient.wallet(:coins) + source_wallet.credit(900, category: :top_up) transfer = source_wallet.transfer_to( target_wallet, @@ -17,13 +20,153 @@ class Wallets::TransferTest < ActiveSupport::TestCase assert transfer.persisted? assert_equal "coins", transfer.asset_code assert_equal 250, transfer.amount - assert_equal 750, source_wallet.reload.balance - assert_equal 370, target_wallet.reload.balance + assert_equal "preserve", transfer.expiration_policy + assert_equal 650, source_wallet.reload.balance + assert_equal 250, target_wallet.reload.balance assert_equal transfer.id, transfer.outbound_transaction.transfer_id - assert_equal transfer.id, transfer.inbound_transaction.transfer_id + assert_equal [transfer.id], transfer.inbound_transactions.pluck(:transfer_id).uniq + assert_equal 1, transfer.inbound_transactions.count assert_equal "peer_payment", transfer.category end + test "preserves expiration on the inbound transfer by default" do + sender = create_wallet(users(:new_user), asset_code: :data_mb, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :data_mb, initial_balance: 0) + original_credit = sender.credit(10_240, category: :top_up, expires_at: 21.days.from_now) + + transfer = sender.transfer_to(recipient, 3_072, category: :gift) + inbound = transfer.inbound_transactions.sole + + assert_equal "preserve", transfer.expiration_policy + assert_equal 3_072, inbound.amount + assert_equal original_credit.expires_at.to_i, inbound.expires_at.to_i + assert_equal transfer.outbound_transaction.id, transfer.transactions.debits.sole.id + assert_equal [inbound.id], transfer.transactions.credits.pluck(:id) + end + + test "preserve splits inbound transfer legs across multiple source expirations" do + sender = create_wallet(users(:new_user), asset_code: :wood, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :wood, initial_balance: 0) + earliest_bucket = sender.credit(100, category: :reward, expires_at: 5.days.from_now) + later_bucket = sender.credit(80, category: :reward, expires_at: 20.days.from_now) + + transfer = sender.transfer_to(recipient, 130, category: :gift) + inbound_legs = transfer.inbound_transactions.order(:expires_at, :id).to_a + + assert_equal 2, inbound_legs.size + assert_nil transfer.inbound_transaction + assert_equal [100, 30], inbound_legs.map(&:amount) + assert_equal [earliest_bucket.expires_at.to_i, later_bucket.expires_at.to_i], inbound_legs.map { |tx| tx.expires_at.to_i } + assert_equal 130, inbound_legs.sum(&:amount) + end + + test "preserve groups inbound transfer legs by shared expiration" do + sender = create_wallet(users(:new_user), asset_code: :stone, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :stone, initial_balance: 0) + shared_expiration = 14.days.from_now + sender.credit(60, category: :reward, expires_at: shared_expiration) + sender.credit(40, category: :reward, expires_at: shared_expiration) + + transfer = sender.transfer_to(recipient, 75, category: :gift) + inbound = transfer.inbound_transactions.sole + + assert_equal 75, inbound.amount + assert_equal shared_expiration.to_i, inbound.expires_at.to_i + end + + test "none expiration policy creates evergreen inbound credits" do + sender = create_wallet(users(:new_user), asset_code: :event_tokens, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :event_tokens, initial_balance: 0) + sender.credit(100, category: :reward, expires_at: 10.days.from_now) + + transfer = sender.transfer_to(recipient, 25, category: :gift, expiration_policy: :none) + + assert_equal "none", transfer.expiration_policy + assert_nil transfer.inbound_transactions.sole.expires_at + end + + test "configured transfer expiration policy is used when no explicit override is provided" do + original_policy = Wallets.configuration.transfer_expiration_policy + Wallets.configuration.transfer_expiration_policy = :none + + sender = create_wallet(users(:new_user), asset_code: :tickets, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :tickets, initial_balance: 0) + sender.credit(100, category: :reward, expires_at: 10.days.from_now) + + transfer = sender.transfer_to(recipient, 25, category: :gift) + + assert_equal "none", transfer.expiration_policy + assert_nil transfer.inbound_transactions.sole.expires_at + ensure + Wallets.configuration.transfer_expiration_policy = original_policy + end + + test "fixed expiration override applies the provided expires_at" do + sender = create_wallet(users(:new_user), asset_code: :gems, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :gems, initial_balance: 0) + sender.credit(100, category: :reward, expires_at: 10.days.from_now) + fixed_expiration = 45.days.from_now + + transfer = sender.transfer_to( + recipient, + 25, + category: :gift, + expiration_policy: :fixed, + expires_at: fixed_expiration + ) + + assert_equal "fixed", transfer.expiration_policy + assert_equal fixed_expiration.to_i, transfer.inbound_transactions.sole.expires_at.to_i + end + + test "fixed expiration override requires an expires_at value" do + source_wallet = wallets_wallets(:rich_coins_wallet) + target_wallet = wallets_wallets(:peer_coins_wallet) + + error = assert_raises(ArgumentError) do + source_wallet.transfer_to(target_wallet, 10, category: :peer_payment, expiration_policy: :fixed) + end + + assert_includes error.message, "expires_at" + end + + test "custom expires_at without an explicit policy uses fixed transfer expiration" do + sender = create_wallet(users(:new_user), asset_code: :ore, initial_balance: 0) + recipient = create_wallet(users(:peer_user), asset_code: :ore, initial_balance: 0) + sender.credit(100, category: :reward, expires_at: 8.days.from_now) + fixed_expiration = 30.days.from_now + + transfer = sender.transfer_to(recipient, 25, category: :gift, expires_at: fixed_expiration) + + assert_equal "fixed", transfer.expiration_policy + assert_equal fixed_expiration.to_i, transfer.inbound_transactions.sole.expires_at.to_i + end + + test "rejects unsupported transfer expiration policies" do + source_wallet = wallets_wallets(:rich_coins_wallet) + target_wallet = wallets_wallets(:peer_coins_wallet) + + error = assert_raises(ArgumentError) do + source_wallet.transfer_to(target_wallet, 10, category: :peer_payment, expiration_policy: :fresh_window) + end + + assert_includes error.message, "expiration policy" + end + + test "rejects transfers that exceed available balance even when negatives are enabled" do + original_setting = Wallets.configuration.allow_negative_balance + Wallets.configuration.allow_negative_balance = true + + sender = create_wallet(users(:new_user), asset_code: :credits, initial_balance: 10) + recipient = create_wallet(users(:peer_user), asset_code: :credits, initial_balance: 0) + + assert_raises(Wallets::InsufficientBalance) do + sender.transfer_to(recipient, 25, category: :gift) + end + ensure + Wallets.configuration.allow_negative_balance = original_setting + end + test "rejects transfers across different assets" do source_wallet = wallets_wallets(:rich_coins_wallet) target_wallet = wallets_wallets(:rich_gems_wallet) @@ -40,10 +183,27 @@ class Wallets::TransferTest < ActiveSupport::TestCase from_wallet: wallet, to_wallet: wallet, asset_code: :coins, - amount: 10 + amount: 10, + expiration_policy: :preserve ) refute transfer.valid? assert_includes transfer.errors[:to_wallet], "must be different from from_wallet" end + + test "transfer model validates expiration policy" do + source_wallet = wallets_wallets(:rich_coins_wallet) + target_wallet = wallets_wallets(:peer_coins_wallet) + + transfer = Wallets::Transfer.new( + from_wallet: source_wallet, + to_wallet: target_wallet, + asset_code: :coins, + amount: 10, + expiration_policy: :fresh_window + ) + + refute transfer.valid? + assert_includes transfer.errors[:expiration_policy], "is not included in the list" + end end diff --git a/test/wallets/configuration_test.rb b/test/wallets/configuration_test.rb index 9548847..5682f15 100644 --- a/test/wallets/configuration_test.rb +++ b/test/wallets/configuration_test.rb @@ -11,6 +11,7 @@ class Wallets::ConfigurationTest < ActiveSupport::TestCase assert_equal false, configuration.allow_negative_balance assert_nil configuration.low_balance_threshold assert_equal "wallets_", configuration.table_prefix + assert_equal :preserve, configuration.transfer_expiration_policy assert_nil configuration.on_balance_credited_callback assert_nil configuration.on_balance_debited_callback assert_nil configuration.on_transfer_completed_callback @@ -26,11 +27,13 @@ class Wallets::ConfigurationTest < ActiveSupport::TestCase configuration.additional_categories = [" quest_reward ", :quest_reward, "", "marketplace_sale"] configuration.low_balance_threshold = "25" configuration.table_prefix = "custom_" + configuration.transfer_expiration_policy = "none" assert_equal :eur, configuration.default_asset assert_equal ["quest_reward", "marketplace_sale"], configuration.additional_categories assert_equal 25, configuration.low_balance_threshold assert_equal "custom_", configuration.table_prefix + assert_equal :none, configuration.transfer_expiration_policy end test "runtime model table names follow the configured table prefix" do @@ -52,6 +55,7 @@ class Wallets::ConfigurationTest < ActiveSupport::TestCase assert_raises(ArgumentError) { configuration.additional_categories = "reward" } assert_raises(ArgumentError) { configuration.low_balance_threshold = -1 } assert_raises(ArgumentError) { configuration.table_prefix = "" } + assert_raises(ArgumentError) { configuration.transfer_expiration_policy = :fixed } end test "stores lifecycle callback blocks" do diff --git a/test/wallets/install_templates_test.rb b/test/wallets/install_templates_test.rb index 33e04e2..ab35290 100644 --- a/test/wallets/install_templates_test.rb +++ b/test/wallets/install_templates_test.rb @@ -10,6 +10,9 @@ class Wallets::InstallTemplatesTest < ActiveSupport::TestCase assert_includes template, "create_table wallets_table" assert_includes template, "add_index wallets_table" assert_equal 4, template.scan("t.bigint").size + assert_includes template, 't.string :expiration_policy, null: false, default: "preserve"' + refute_includes template, "outbound_transaction" + refute_includes template, "inbound_transaction" end test "initializer template keeps optional categories and table prefix commented out" do diff --git a/wallets.gemspec b/wallets.gemspec index de30982..20fb6ed 100644 --- a/wallets.gemspec +++ b/wallets.gemspec @@ -23,6 +23,7 @@ Gem::Specification.new do |spec| spec.files = Dir.chdir(__dir__) do Dir.glob("**/*", File::FNM_DOTMATCH).reject do |file| file.start_with?(".git/", "coverage/", "dist/", "test/dummy/log/", "test/dummy/tmp/", "test/dummy/storage/") || + file.end_with?(".gem") || [".", "..", ".DS_Store", "Gemfile.lock", "test/.DS_Store"].include?(file) end end @@ -31,5 +32,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |file| File.basename(file) } spec.require_paths = ["lib"] - spec.add_dependency "rails", ">= 7.2" + spec.add_dependency "rails", ">= 6.1" end From 56382279570a4eb969b6aef9130bc3410183fcf0 Mon Sep 17 00:00:00 2001 From: Javi R <4920956+rameerez@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:27:45 +0000 Subject: [PATCH 2/2] Fix MySQL JSON column default value error in dummy migration MySQL 8+ doesn't allow default values on JSON columns. Use conditional helper methods to set jsonb for PostgreSQL, json for others, and skip the default value for MySQL (models handle nil gracefully). Co-Authored-By: Claude Opus 4.6 --- .../20250212181807_create_wallets_tables.rb | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb b/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb index f69989f..270bec9 100644 --- a/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb +++ b/test/dummy/db/migrate/20250212181807_create_wallets_tables.rb @@ -6,7 +6,7 @@ def change t.references :owner, polymorphic: true, null: false t.string :asset_code, null: false t.bigint :balance, null: false, default: 0 - t.json :metadata, null: false, default: {} + t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end @@ -19,7 +19,7 @@ def change t.bigint :amount, null: false t.string :category, null: false, default: "transfer" t.string :expiration_policy, null: false, default: "preserve" - t.json :metadata, null: false, default: {} + t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end @@ -29,7 +29,7 @@ def change t.string :category, null: false t.datetime :expires_at t.references :transfer, foreign_key: { to_table: :wallets_transfers } - t.json :metadata, null: false, default: {} + t.send(json_column_type, :metadata, null: false, default: json_column_default) t.timestamps end @@ -46,4 +46,18 @@ def change add_index :wallets_transactions, [:expires_at, :id], name: "index_wallet_transactions_on_expires_at_and_id" add_index :wallets_allocations, [:transaction_id, :source_transaction_id], name: "index_wallet_allocations_on_tx_and_source_tx" end + + private + + def json_column_type + return :jsonb if connection.adapter_name.downcase.include?("postgresql") + + :json + end + + def json_column_default + return nil if connection.adapter_name.downcase.include?("mysql") + + {} + end end