usage_credits 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.simplecov +26 -37
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +9 -0
- data/README.md +173 -2
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +12 -4
- data/lib/usage_credits/configuration.rb +18 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +17 -4
- data/lib/usage_credits/models/fulfillment.rb +36 -0
- data/lib/usage_credits/models/transaction.rb +58 -3
- data/lib/usage_credits/models/wallet.rb +56 -0
- data/lib/usage_credits/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 707a34fdb4e9cfa1ca2cb0ccd128baf514c3cc68512afdd93706c288172f296f
|
|
4
|
+
data.tar.gz: 847ecdc0ffef49fe9b6a948dc6d199fa88ab1ea0d4c5486b26ff5b55deb4838c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d778d2cd412b1754eb79a34d9f405241bd38461dbaf5021e1c948c570b09ffc36676ab742b23868754d7c9ddc94259afb383ff4e987abf26f243e3ed20ea767
|
|
7
|
+
data.tar.gz: b918f017dddbbd325db6e71c4d4163a01164c0d5ebf3007f07a0e0853bc0c898a486eb6905a6327ae7ffe02d7dda5692a12111694c592318222740e6b1ae57ed
|
data/.simplecov
CHANGED
|
@@ -1,48 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
SimpleCov
|
|
4
|
-
|
|
5
|
-
coverage_dir 'coverage'
|
|
3
|
+
# SimpleCov configuration file (auto-loaded before test suite)
|
|
4
|
+
# This keeps test_helper.rb clean and follows best practices
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
|
+
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
|
+
|
|
10
|
+
# Track coverage for the lib directory (gem source code)
|
|
11
|
+
add_filter "/test/"
|
|
12
|
+
|
|
13
|
+
# Track Ruby files in lib directory
|
|
14
|
+
track_files "lib/**/*.rb"
|
|
15
|
+
|
|
16
|
+
# Enable branch coverage for more detailed metrics
|
|
8
17
|
enable_coverage :branch
|
|
9
18
|
|
|
10
19
|
# Set minimum coverage threshold to prevent coverage regression
|
|
11
|
-
# Current coverage: Line
|
|
12
|
-
|
|
13
|
-
minimum_coverage line: 75, branch: 60
|
|
14
|
-
|
|
15
|
-
# Add custom groups for better organization
|
|
16
|
-
add_group 'Models', 'lib/usage_credits/models'
|
|
17
|
-
add_group 'Services', 'lib/usage_credits/services'
|
|
18
|
-
add_group 'Helpers', 'lib/usage_credits/helpers'
|
|
19
|
-
add_group 'Jobs', 'lib/usage_credits/jobs'
|
|
20
|
-
add_group 'Concerns', 'lib/usage_credits/models/concerns'
|
|
21
|
-
add_group 'DSL', ['lib/usage_credits/operation.rb', 'lib/usage_credits/credit_pack.rb', 'lib/usage_credits/credit_subscription_plan.rb']
|
|
22
|
-
|
|
23
|
-
# Filter out files we don't want to track
|
|
24
|
-
add_filter '/test/'
|
|
25
|
-
add_filter '/spec/'
|
|
26
|
-
add_filter '/config/'
|
|
27
|
-
add_filter '/db/'
|
|
28
|
-
add_filter '/vendor/'
|
|
29
|
-
add_filter '/bin/'
|
|
30
|
-
|
|
31
|
-
# Track all Ruby files in lib
|
|
32
|
-
track_files 'lib/**/*.rb'
|
|
20
|
+
# Current coverage: Line 88.56%, Branch 81.17%
|
|
21
|
+
minimum_coverage line: 80, branch: 75
|
|
33
22
|
|
|
34
23
|
# Disambiguate parallel test runs
|
|
35
24
|
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
|
25
|
+
end
|
|
36
26
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
merge_timeout 3600
|
|
27
|
+
# Print coverage summary to terminal after tests complete
|
|
28
|
+
SimpleCov.at_exit do
|
|
29
|
+
SimpleCov.result.format!
|
|
30
|
+
puts "\n" + "=" * 60
|
|
31
|
+
puts "COVERAGE SUMMARY"
|
|
32
|
+
puts "=" * 60
|
|
33
|
+
puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
|
|
34
|
+
branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
|
|
35
|
+
puts "Branch Coverage: #{branch_coverage}%"
|
|
36
|
+
puts "=" * 60
|
|
48
37
|
end
|
data/AGENTS.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
This file provides guidance to AI Agents (like OpenAI's Codex, Claude Code, etc) when working with code in this repository.
|
|
3
|
+
This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
|
|
4
4
|
|
|
5
5
|
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [0.5.0] - 2026-03-15
|
|
2
|
+
|
|
3
|
+
- Add configurable transaction categories via `config.additional_categories` for money-like wallet use cases (marketplaces, fintech) by @rameerez in https://github.com/rameerez/usage_credits/pull/29
|
|
4
|
+
|
|
5
|
+
## [0.4.0] - 2026-01-16
|
|
6
|
+
|
|
7
|
+
- Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
|
|
8
|
+
- Add MySQL support and multi-database CI testing by @rameerez in https://github.com/rameerez/usage_credits/pull/28
|
|
9
|
+
|
|
1
10
|
## [0.3.0] - 2026-01-15
|
|
2
11
|
|
|
3
12
|
- Add lifecycle callbacks by @rameerez in https://github.com/rameerez/usage_credits/pull/25
|
data/README.md
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/usage_credits) [](https://github.com/rameerez/usage_credits/actions)
|
|
4
4
|
|
|
5
5
|
> [!TIP]
|
|
6
|
-
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=usage_credits)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=usage_credits)!
|
|
7
7
|
|
|
8
8
|
`usage_credits` allows your users to have in-app credits / tokens they can use to perform operations.
|
|
9
9
|
|
|
10
|
-
✨ Perfect for SaaS, AI apps, games,
|
|
10
|
+
✨ Perfect for SaaS, AI apps, games, API products, and **marketplace wallets** that want to implement usage-based pricing or track money-like balances.
|
|
11
|
+
|
|
12
|
+
> **Not just for credits!** While the gem is called "usage_credits", it's built on a production-grade double-entry ledger with row-level locking, FIFO allocation, and full audit trails. You can use it for marketplace seller balances, in-app wallets, reward points, or any system that needs to track money-like assets with proper accounting. [See the "Beyond credits" section](#beyond-credits-using-this-gem-for-money-like-wallets-and-payouts) for examples.
|
|
11
13
|
|
|
12
14
|
[ 🟢 [Live interactive demo website](https://usagecredits.com/) ] [ 🎥 [Quick video overview](https://x.com/rameerez/status/1890419563189195260) ]
|
|
13
15
|
|
|
@@ -578,6 +580,35 @@ This makes it easy to:
|
|
|
578
580
|
- Generate detailed invoices
|
|
579
581
|
- Monitor usage patterns
|
|
580
582
|
|
|
583
|
+
### Running balance (balance after each transaction)
|
|
584
|
+
|
|
585
|
+
Every transaction automatically tracks the wallet balance before and after it was applied, like you would find in a bank statement:
|
|
586
|
+
|
|
587
|
+
```ruby
|
|
588
|
+
user.credit_history.each do |tx|
|
|
589
|
+
puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.formatted_amount}"
|
|
590
|
+
puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Output:
|
|
594
|
+
# 2024-12-16: +1000 credits
|
|
595
|
+
# Balance: 0 → 1000
|
|
596
|
+
# 2024-12-26: +500 credits
|
|
597
|
+
# Balance: 1000 → 1500
|
|
598
|
+
# 2025-01-14: -50 credits
|
|
599
|
+
# Balance: 1500 → 1450
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
This is useful for building transaction history UIs, generating statements, or debugging balance issues. Each transaction provides:
|
|
603
|
+
|
|
604
|
+
```ruby
|
|
605
|
+
transaction.balance_before # Balance before this transaction
|
|
606
|
+
transaction.balance_after # Balance after this transaction
|
|
607
|
+
transaction.formatted_balance_after # Formatted (e.g., "1450 credits")
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
`balance_before` and `balance_after` return `nil` if no balance is found (for transactions created before this feature was added)
|
|
611
|
+
|
|
581
612
|
### Custom credit formatting
|
|
582
613
|
|
|
583
614
|
A minor thing, but if you want to use the `@transaction.formatted_amount` helper, you can specify the format:
|
|
@@ -598,6 +629,146 @@ Which will get you:
|
|
|
598
629
|
|
|
599
630
|
It's useful if you want to name your credits something else (tokens, virtual currency, tasks, in-app gems, whatever) and you want the name to be consistent.
|
|
600
631
|
|
|
632
|
+
## Beyond credits: using this gem for money-like wallets and payouts
|
|
633
|
+
|
|
634
|
+
While this gem is called `usage_credits`, the underlying architecture is a **production-grade double-entry ledger** with row-level locking, FIFO allocation, and full audit trails. This makes it suitable for more than just API credits — you can use it as a wallet system for **money-like assets**, **marketplace payouts**, **in-app balances**, and more.
|
|
635
|
+
|
|
636
|
+
### Custom transaction categories
|
|
637
|
+
|
|
638
|
+
By default, the gem includes categories like `signup_bonus`, `operation_charge`, `subscription_credits`, etc. But you can extend these with your own categories for your specific use case:
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
# config/initializers/usage_credits.rb
|
|
642
|
+
UsageCredits.configure do |config|
|
|
643
|
+
# Add custom categories for your business logic
|
|
644
|
+
config.additional_categories = %w[
|
|
645
|
+
payment_received
|
|
646
|
+
payment_sent
|
|
647
|
+
payout_requested
|
|
648
|
+
platform_fee
|
|
649
|
+
refund
|
|
650
|
+
tip
|
|
651
|
+
cashback
|
|
652
|
+
]
|
|
653
|
+
end
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
These custom categories work exactly like the built-in ones — they're validated, tracked in transaction history, and available for filtering/querying.
|
|
657
|
+
|
|
658
|
+
### Example: Marketplace with seller payouts
|
|
659
|
+
|
|
660
|
+
Imagine you're building a marketplace where sellers earn money and can withdraw their balance. Here's how you'd implement it with `usage_credits`:
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
# app/models/user.rb
|
|
664
|
+
class User < ApplicationRecord
|
|
665
|
+
has_credits # Each user gets a wallet
|
|
666
|
+
|
|
667
|
+
def request_payout(amount_cents)
|
|
668
|
+
# In production, wrap in wallet.with_lock { } to prevent race conditions
|
|
669
|
+
raise "Insufficient balance" if credits < amount_cents
|
|
670
|
+
|
|
671
|
+
wallet.deduct_credits(
|
|
672
|
+
amount_cents,
|
|
673
|
+
category: :payout_requested,
|
|
674
|
+
metadata: {
|
|
675
|
+
requested_at: Time.current,
|
|
676
|
+
payout_method: stripe_account_id
|
|
677
|
+
}
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
PayoutJob.perform_later(self, amount_cents)
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# app/services/order_payment_service.rb
|
|
685
|
+
class OrderPaymentService
|
|
686
|
+
def initialize(order)
|
|
687
|
+
@order = order
|
|
688
|
+
@buyer = order.buyer
|
|
689
|
+
@seller = order.seller
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def process!
|
|
693
|
+
platform_fee = (@order.total_cents * 0.10).to_i # 10% fee
|
|
694
|
+
seller_amount = @order.total_cents - platform_fee
|
|
695
|
+
|
|
696
|
+
ActiveRecord::Base.transaction do
|
|
697
|
+
# Credit the seller (net of platform fee)
|
|
698
|
+
@seller.wallet.add_credits(
|
|
699
|
+
seller_amount,
|
|
700
|
+
category: :payment_received,
|
|
701
|
+
metadata: {
|
|
702
|
+
order_id: @order.id,
|
|
703
|
+
gross_amount: @order.total_cents,
|
|
704
|
+
platform_fee: platform_fee,
|
|
705
|
+
buyer_id: @buyer.id
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
@order.update!(paid: true)
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Now you have:
|
|
716
|
+
- **Full audit trail**: Every transaction is logged with metadata
|
|
717
|
+
- **Balance tracking**: `@seller.credits` returns current balance in cents
|
|
718
|
+
- **Transaction history**: `@seller.credit_history.by_category(:payment_received)`
|
|
719
|
+
- **Concurrency safety**: Row-level locks prevent double-spending
|
|
720
|
+
- **Running balance**: Each transaction stores `balance_before` and `balance_after`
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
# Show transaction history with running balance
|
|
724
|
+
@seller.credit_history.recent.each do |tx|
|
|
725
|
+
puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.category}"
|
|
726
|
+
puts " Amount: #{tx.amount} cents"
|
|
727
|
+
puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
|
|
728
|
+
puts " Order: ##{tx.metadata['order_id']}" if tx.metadata['order_id']
|
|
729
|
+
end
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
### Why this works for money
|
|
733
|
+
|
|
734
|
+
The gem's architecture gives you everything you'd need for a money-handling system:
|
|
735
|
+
|
|
736
|
+
| Feature | How it helps |
|
|
737
|
+
|---------|--------------|
|
|
738
|
+
| Double-entry ledger | Every credit has a corresponding debit source tracked via allocations |
|
|
739
|
+
| Immutable transactions | Append-only — no edits, only new entries (required for financial audit) |
|
|
740
|
+
| Row-level locking | Prevents race conditions and double-spending |
|
|
741
|
+
| FIFO allocation | When spending, oldest credits are used first (important for expiring balances) |
|
|
742
|
+
| Balance snapshots | Each transaction records balance before/after for reconciliation |
|
|
743
|
+
| Rich metadata | Store order IDs, user IDs, payment references — whatever you need for audit |
|
|
744
|
+
|
|
745
|
+
### A note on multi-currency
|
|
746
|
+
|
|
747
|
+
Currently, the gem uses a single currency per installation (configured via `config.default_currency`). All amounts are stored as integers (cents) to avoid floating-point issues.
|
|
748
|
+
|
|
749
|
+
If you need multi-currency support, you could:
|
|
750
|
+
1. Store amounts in the smallest unit of each currency (cents, pence, etc.)
|
|
751
|
+
2. Use metadata to track the currency per transaction
|
|
752
|
+
3. Handle conversion at the application layer
|
|
753
|
+
|
|
754
|
+
Multi-currency wallets (one wallet per currency per user) is on the roadmap for a future version. For now, if you need this, you'd run separate wallet instances or handle it at the application level.
|
|
755
|
+
|
|
756
|
+
### Naming your "credits"
|
|
757
|
+
|
|
758
|
+
Remember you can customize how credits are displayed:
|
|
759
|
+
|
|
760
|
+
```ruby
|
|
761
|
+
UsageCredits.configure do |config|
|
|
762
|
+
config.format_credits do |amount|
|
|
763
|
+
# Display as money
|
|
764
|
+
"$#{(amount / 100.0).round(2)}"
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
@user.credit_history.last.formatted_amount
|
|
769
|
+
# => "+$25.00"
|
|
770
|
+
```
|
|
771
|
+
|
|
601
772
|
## Demo Rails app
|
|
602
773
|
|
|
603
774
|
There's a demo Rails app showcasing the features in the `usage_credits` gem under `test/dummy`. It's currently deployed to `usagecredits.com`. If you want to run it yourself locally, you can just clone this repo, `cd` into the `test/dummy` folder, and then `bundle` and `rails s` to launch it. You can examine the code of the demo app to better understand the gem.
|
|
@@ -7,7 +7,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
7
7
|
create_table :usage_credits_wallets, id: primary_key_type do |t|
|
|
8
8
|
t.references :owner, polymorphic: true, null: false, type: foreign_key_type
|
|
9
9
|
t.integer :balance, null: false, default: 0
|
|
10
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
10
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
11
11
|
|
|
12
12
|
t.timestamps
|
|
13
13
|
end
|
|
@@ -18,7 +18,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
18
18
|
t.string :category, null: false
|
|
19
19
|
t.datetime :expires_at
|
|
20
20
|
t.references :fulfillment, type: foreign_key_type
|
|
21
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
21
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
22
22
|
|
|
23
23
|
t.timestamps
|
|
24
24
|
end
|
|
@@ -32,7 +32,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
32
32
|
t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
|
|
33
33
|
t.string :fulfillment_period # "2.months", "15.days", etc. (nil for one-time)
|
|
34
34
|
t.datetime :stops_at # When to stop performing fulfillments
|
|
35
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
35
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
36
36
|
|
|
37
37
|
t.timestamps
|
|
38
38
|
end
|
|
@@ -85,4 +85,12 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
85
85
|
return :jsonb if connection.adapter_name.downcase.include?('postgresql')
|
|
86
86
|
:json
|
|
87
87
|
end
|
|
88
|
-
|
|
88
|
+
|
|
89
|
+
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
90
|
+
# Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL.
|
|
91
|
+
# Models handle nil metadata gracefully by defaulting to {} in their accessors.
|
|
92
|
+
def json_column_default
|
|
93
|
+
return nil if connection.adapter_name.downcase.include?('mysql')
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -30,6 +30,9 @@ module UsageCredits
|
|
|
30
30
|
|
|
31
31
|
attr_reader :fulfillment_grace_period
|
|
32
32
|
|
|
33
|
+
# Custom transaction categories that extend the default set
|
|
34
|
+
attr_reader :additional_categories
|
|
35
|
+
|
|
33
36
|
# Minimum allowed fulfillment period for subscription plans.
|
|
34
37
|
# Defaults to 1.day to prevent accidental 1-second refill loops in production.
|
|
35
38
|
# Can be set to shorter periods (e.g., 2.seconds) in development/test for faster iteration.
|
|
@@ -80,6 +83,9 @@ module UsageCredits
|
|
|
80
83
|
# Minimum fulfillment period - prevents accidental 1-second refill loops in production
|
|
81
84
|
@min_fulfillment_period = 1.day
|
|
82
85
|
|
|
86
|
+
# Custom transaction categories (empty by default, apps can extend)
|
|
87
|
+
@additional_categories = []
|
|
88
|
+
|
|
83
89
|
@allow_negative_balance = false
|
|
84
90
|
@low_balance_threshold = nil
|
|
85
91
|
@low_balance_callback = nil # Called when user hits low_balance_threshold
|
|
@@ -201,6 +207,18 @@ module UsageCredits
|
|
|
201
207
|
@min_fulfillment_period = value
|
|
202
208
|
end
|
|
203
209
|
|
|
210
|
+
# Set additional transaction categories with validation
|
|
211
|
+
# These extend the default categories defined in Transaction::DEFAULT_CATEGORIES
|
|
212
|
+
# @param categories [Array<String, Symbol>] Array of category names
|
|
213
|
+
def additional_categories=(categories)
|
|
214
|
+
raise ArgumentError, "Additional categories must be an array" unless categories.is_a?(Array)
|
|
215
|
+
|
|
216
|
+
# Convert to strings and filter out blank values
|
|
217
|
+
validated = categories.map { |cat| cat.to_s.strip }.reject(&:blank?)
|
|
218
|
+
|
|
219
|
+
@additional_categories = validated
|
|
220
|
+
end
|
|
221
|
+
|
|
204
222
|
# =========================================
|
|
205
223
|
# Callback & Formatter Configuration
|
|
206
224
|
# =========================================
|
|
@@ -110,9 +110,15 @@ module UsageCredits
|
|
|
110
110
|
if adapter.include?("postgres")
|
|
111
111
|
# PostgreSQL supports the @> JSON containment operator.
|
|
112
112
|
transactions.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
|
|
113
|
+
elsif adapter.include?("mysql")
|
|
114
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
115
|
+
transactions.exists?([
|
|
116
|
+
"JSON_EXTRACT(metadata, '$.purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_fulfilled') = CAST('true' AS JSON)",
|
|
117
|
+
id
|
|
118
|
+
])
|
|
113
119
|
else
|
|
114
|
-
#
|
|
115
|
-
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id,
|
|
120
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
121
|
+
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, 1])
|
|
116
122
|
end
|
|
117
123
|
rescue ActiveRecord::StatementInvalid
|
|
118
124
|
# If the SQL query fails (for example, if JSON_EXTRACT isn’t supported),
|
|
@@ -229,11 +235,18 @@ module UsageCredits
|
|
|
229
235
|
{ refunded_purchase_charge_id: id, credits_refunded: true }.to_json
|
|
230
236
|
)
|
|
231
237
|
return filtered.sum { |tx| -tx.amount }
|
|
238
|
+
elsif adapter.include?("mysql")
|
|
239
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
240
|
+
filtered = transactions.where(
|
|
241
|
+
"JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)",
|
|
242
|
+
id
|
|
243
|
+
)
|
|
244
|
+
return filtered.sum { |tx| -tx.amount }
|
|
232
245
|
else
|
|
233
|
-
# SQLite
|
|
246
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
234
247
|
filtered = transactions.where(
|
|
235
248
|
"json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
|
|
236
|
-
id,
|
|
249
|
+
id, 1
|
|
237
250
|
)
|
|
238
251
|
return filtered.sum { |tx| -tx.amount }
|
|
239
252
|
end
|
|
@@ -18,6 +18,31 @@ module UsageCredits
|
|
|
18
18
|
validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at },
|
|
19
19
|
if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
|
|
20
20
|
|
|
21
|
+
# =========================================
|
|
22
|
+
# Metadata Handling
|
|
23
|
+
# =========================================
|
|
24
|
+
|
|
25
|
+
# Sync in-place modifications to metadata before saving
|
|
26
|
+
before_save :sync_metadata_cache
|
|
27
|
+
|
|
28
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
29
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
30
|
+
def metadata
|
|
31
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Set metadata, ensuring consistent storage format
|
|
35
|
+
def metadata=(hash)
|
|
36
|
+
@indifferent_metadata = nil # Clear cache
|
|
37
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
41
|
+
def reload(*)
|
|
42
|
+
@indifferent_metadata = nil
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
# Only get fulfillments that are due AND not stopped
|
|
22
47
|
scope :due_for_fulfillment, -> {
|
|
23
48
|
where("next_fulfillment_at <= ?", Time.current)
|
|
@@ -65,6 +90,17 @@ module UsageCredits
|
|
|
65
90
|
|
|
66
91
|
private
|
|
67
92
|
|
|
93
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
94
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
95
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
96
|
+
def sync_metadata_cache
|
|
97
|
+
if @indifferent_metadata
|
|
98
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
99
|
+
elsif read_attribute(:metadata).nil?
|
|
100
|
+
write_attribute(:metadata, {})
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
68
104
|
def valid_fulfillment_period_format
|
|
69
105
|
unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period)
|
|
70
106
|
errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
|
|
@@ -15,8 +15,8 @@ module UsageCredits
|
|
|
15
15
|
# Transaction Categories
|
|
16
16
|
# =========================================
|
|
17
17
|
|
|
18
|
-
#
|
|
19
|
-
|
|
18
|
+
# Default transaction types, grouped by purpose:
|
|
19
|
+
DEFAULT_CATEGORIES = [
|
|
20
20
|
# Bonus credits
|
|
21
21
|
"signup_bonus", # Initial signup bonus
|
|
22
22
|
"referral_bonus", # Referral reward bonus
|
|
@@ -40,6 +40,16 @@ module UsageCredits
|
|
|
40
40
|
"credit_deducted" # Generic deduction
|
|
41
41
|
].freeze
|
|
42
42
|
|
|
43
|
+
# All valid categories: defaults + any custom categories added via config
|
|
44
|
+
# @return [Array<String>] Combined list of valid category names
|
|
45
|
+
def self.categories
|
|
46
|
+
(DEFAULT_CATEGORIES + UsageCredits.configuration.additional_categories).uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Backwards compatibility: CATEGORIES constant still works
|
|
50
|
+
# but prefer using Transaction.categories for dynamic lookup
|
|
51
|
+
CATEGORIES = DEFAULT_CATEGORIES
|
|
52
|
+
|
|
43
53
|
# =========================================
|
|
44
54
|
# Associations & Validations
|
|
45
55
|
# =========================================
|
|
@@ -59,7 +69,7 @@ module UsageCredits
|
|
|
59
69
|
dependent: :destroy
|
|
60
70
|
|
|
61
71
|
validates :amount, presence: true, numericality: { only_integer: true }
|
|
62
|
-
validates :category, presence: true, inclusion: { in:
|
|
72
|
+
validates :category, presence: true, inclusion: { in: ->(record) { Transaction.categories } }
|
|
63
73
|
|
|
64
74
|
validate :remaining_amount_cannot_be_negative
|
|
65
75
|
|
|
@@ -116,6 +126,23 @@ module UsageCredits
|
|
|
116
126
|
amount - allocated_amount
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
# =========================================
|
|
130
|
+
# Balance After Transaction
|
|
131
|
+
# =========================================
|
|
132
|
+
|
|
133
|
+
# Get the balance after this transaction was applied
|
|
134
|
+
# Returns nil for transactions created before this feature was added
|
|
135
|
+
def balance_after
|
|
136
|
+
metadata[:balance_after]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Get the balance before this transaction was applied
|
|
140
|
+
# Returns the stored value if available, otherwise nil
|
|
141
|
+
# Note: For transactions created before this feature, returns nil
|
|
142
|
+
def balance_before
|
|
143
|
+
metadata[:balance_before]
|
|
144
|
+
end
|
|
145
|
+
|
|
119
146
|
# =========================================
|
|
120
147
|
# Display Formatting
|
|
121
148
|
# =========================================
|
|
@@ -126,6 +153,13 @@ module UsageCredits
|
|
|
126
153
|
"#{prefix}#{UsageCredits.configuration.credit_formatter.call(amount)}"
|
|
127
154
|
end
|
|
128
155
|
|
|
156
|
+
# Format the balance after for display (e.g., "500 credits")
|
|
157
|
+
# Returns nil if balance_after is not stored
|
|
158
|
+
def formatted_balance_after
|
|
159
|
+
return nil unless balance_after
|
|
160
|
+
UsageCredits.configuration.credit_formatter.call(balance_after)
|
|
161
|
+
end
|
|
162
|
+
|
|
129
163
|
# Get a human-readable description of what this transaction represents
|
|
130
164
|
def description
|
|
131
165
|
# Custom description takes precedence
|
|
@@ -142,7 +176,11 @@ module UsageCredits
|
|
|
142
176
|
# Metadata Handling
|
|
143
177
|
# =========================================
|
|
144
178
|
|
|
179
|
+
# Sync in-place modifications to metadata before saving
|
|
180
|
+
before_save :sync_metadata_cache
|
|
181
|
+
|
|
145
182
|
# Get metadata with indifferent access (string/symbol keys)
|
|
183
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
146
184
|
def metadata
|
|
147
185
|
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
148
186
|
end
|
|
@@ -153,8 +191,25 @@ module UsageCredits
|
|
|
153
191
|
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
154
192
|
end
|
|
155
193
|
|
|
194
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
195
|
+
def reload(*)
|
|
196
|
+
@indifferent_metadata = nil
|
|
197
|
+
super
|
|
198
|
+
end
|
|
199
|
+
|
|
156
200
|
private
|
|
157
201
|
|
|
202
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
203
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
204
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
205
|
+
def sync_metadata_cache
|
|
206
|
+
if @indifferent_metadata
|
|
207
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
208
|
+
elsif read_attribute(:metadata).nil?
|
|
209
|
+
write_attribute(:metadata, {})
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
158
213
|
# Format operation charge descriptions (e.g., "Process Video (-10 credits)")
|
|
159
214
|
def operation_description
|
|
160
215
|
operation = metadata["operation"]&.to_s&.titleize
|
|
@@ -25,6 +25,31 @@ module UsageCredits
|
|
|
25
25
|
|
|
26
26
|
validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
|
|
27
27
|
|
|
28
|
+
# =========================================
|
|
29
|
+
# Metadata Handling
|
|
30
|
+
# =========================================
|
|
31
|
+
|
|
32
|
+
# Sync in-place modifications to metadata before saving
|
|
33
|
+
before_save :sync_metadata_cache
|
|
34
|
+
|
|
35
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
36
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
37
|
+
def metadata
|
|
38
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set metadata, ensuring consistent storage format
|
|
42
|
+
def metadata=(hash)
|
|
43
|
+
@indifferent_metadata = nil # Clear cache
|
|
44
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
48
|
+
def reload(*)
|
|
49
|
+
@indifferent_metadata = nil
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
28
53
|
# =========================================
|
|
29
54
|
# Credit Balance & History
|
|
30
55
|
# =========================================
|
|
@@ -183,6 +208,16 @@ module UsageCredits
|
|
|
183
208
|
self.balance = credits
|
|
184
209
|
save!
|
|
185
210
|
|
|
211
|
+
# Store balance information in transaction metadata for audit trail.
|
|
212
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
213
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
214
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
215
|
+
# to ensure system-set values are authoritative.
|
|
216
|
+
transaction.update!(metadata: transaction.metadata.merge(
|
|
217
|
+
balance_before: previous_balance,
|
|
218
|
+
balance_after: balance
|
|
219
|
+
))
|
|
220
|
+
|
|
186
221
|
# Dispatch callback with full context
|
|
187
222
|
UsageCredits::Callbacks.dispatch(:credits_added,
|
|
188
223
|
wallet: self,
|
|
@@ -277,6 +312,16 @@ module UsageCredits
|
|
|
277
312
|
self.balance = credits
|
|
278
313
|
save!
|
|
279
314
|
|
|
315
|
+
# Store balance information in transaction metadata for audit trail.
|
|
316
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
317
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
318
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
319
|
+
# to ensure system-set values are authoritative.
|
|
320
|
+
spend_tx.update!(metadata: spend_tx.metadata.merge(
|
|
321
|
+
balance_before: previous_balance,
|
|
322
|
+
balance_after: balance
|
|
323
|
+
))
|
|
324
|
+
|
|
280
325
|
# Dispatch credits_deducted callback
|
|
281
326
|
UsageCredits::Callbacks.dispatch(:credits_deducted,
|
|
282
327
|
wallet: self,
|
|
@@ -314,6 +359,17 @@ module UsageCredits
|
|
|
314
359
|
|
|
315
360
|
private
|
|
316
361
|
|
|
362
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
363
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
364
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
365
|
+
def sync_metadata_cache
|
|
366
|
+
if @indifferent_metadata
|
|
367
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
368
|
+
elsif read_attribute(:metadata).nil?
|
|
369
|
+
write_attribute(:metadata, {})
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
317
373
|
# =========================================
|
|
318
374
|
# Helper Methods
|
|
319
375
|
# =========================================
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usage_credits
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-15 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|