usage_credits 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -2
- data/README.md +23 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +1 -1
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +62 -9
- data/lib/usage_credits/models/transaction.rb +2 -1
- data/lib/usage_credits/models/wallet.rb +16 -7
- 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: '09e65a66af34a0f2bf35a72d59fdd2d9d9799aaeaa1ac8b7628f222f65e5beab'
|
4
|
+
data.tar.gz: 2dc466a29bc36385aeee7c8bbb77833e521c2207e84ac6cf00543253d02af74e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c1a6c0fc9dab7e315d67b6071b38d5e00b386bc21830438636405296b32fbabb510f0f31a77d995ea8d03972b939ebcf25aba3baa7f03847a66d2ee55b4a6a62
|
7
|
+
data.tar.gz: fd4c0225c47dfca3903249b9456d428c9012060acb4f8beceb4c577e5673a4f650cdfd0ba74035bae5b253284e8ccb73eeb87a11b815fed4b9626f00968bf84a
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,11 @@
|
|
1
|
-
## [
|
1
|
+
## [0.1.1] - 2025-01-14
|
2
2
|
|
3
|
-
|
3
|
+
- Rename `Wallet#subscriptions` to `Wallet.credit_subscriptions` so that it doesn’t override the Pay gem’s own subscriptions association on `User`
|
4
|
+
- Add non-postgres fallbacks for PostgreSQL-only operations (namely `@>` to access json attributes)
|
5
|
+
- Add optional `expires_at` to `give_credits` so you can expire any batch of credits at any arbitrary date in the future
|
6
|
+
- Add Allocation associations to the Wallet model
|
7
|
+
- Add demo Rails app to showcase the gem features
|
8
|
+
|
9
|
+
## [0.1.0] - 2025-01-12
|
4
10
|
|
5
11
|
- Initial release
|
data/README.md
CHANGED
@@ -123,6 +123,7 @@ That's it! Your app now has a credits system. Let's see how to use it.
|
|
123
123
|
1. Users can get credits by:
|
124
124
|
- Purchasing credit packs (e.g., "1000 credits for $49")
|
125
125
|
- Having a subscription (e.g., "Pro plan includes 10,000 credits/month")
|
126
|
+
- Arbitrary bonuses at any point (completing signup, referring a friend, etc.)
|
126
127
|
|
127
128
|
2. Users spend credits on operations you define:
|
128
129
|
- "Sending an email costs 1 credit"
|
@@ -280,6 +281,28 @@ UsageCredits.configure do |config|
|
|
280
281
|
end
|
281
282
|
```
|
282
283
|
|
284
|
+
## Award bonus credits
|
285
|
+
|
286
|
+
You might want to award bonus credits to your users for arbitrary actions at any point, like referring a friend, completing signup, or any other reason.
|
287
|
+
|
288
|
+
To do that, you can just do:
|
289
|
+
|
290
|
+
```ruby
|
291
|
+
@user.give_credits(100, reason: "referral")
|
292
|
+
```
|
293
|
+
|
294
|
+
And the user will get the credits with the proper category in the transaction ledger (so bonus credits get differentiated from purchases / subscriptions for audit trail purposes)
|
295
|
+
|
296
|
+
Providing a reason for giving credits is entirely optional (it just helps you if you need to use or analyze the audit trail) – if you don't specify any reason, `:manual_adjustment` is the default reason.
|
297
|
+
|
298
|
+
You can also give credits with arbitrary expiration dates:
|
299
|
+
```ruby
|
300
|
+
@user.give_credits(100, expires_at: 1.month.from_now)
|
301
|
+
```
|
302
|
+
|
303
|
+
So you can expire any batch of credits at any date in the future.
|
304
|
+
|
305
|
+
|
283
306
|
## Sell credit packs
|
284
307
|
|
285
308
|
> [!IMPORTANT]
|
@@ -17,10 +17,18 @@ module UsageCredits
|
|
17
17
|
end
|
18
18
|
|
19
19
|
def succeeded?
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
20
|
+
case type
|
21
|
+
when "Pay::Stripe::Charge"
|
22
|
+
status = data["status"] || data[:status]
|
23
|
+
return true if status == "succeeded"
|
24
|
+
return true if data["amount_captured"].to_i == amount.to_i
|
25
|
+
end
|
26
|
+
|
27
|
+
# Are Pay::Charge objects guaranteed to be created only after a successful payment?
|
28
|
+
# Is the existence of a Pay::Charge object in the database a guarantee that the payment went through?
|
29
|
+
# I'm implementing this `succeeded?` method just out of precaution
|
30
|
+
# TODO: Implement for more payment processors? Or just drop this method if it's unnecessary
|
31
|
+
true
|
24
32
|
end
|
25
33
|
|
26
34
|
def refunded?
|
@@ -58,9 +66,34 @@ module UsageCredits
|
|
58
66
|
# First check if there's a fulfillment record for this charge
|
59
67
|
return true if UsageCredits::Fulfillment.exists?(source: self)
|
60
68
|
|
61
|
-
# Fallback: check transactions directly
|
62
|
-
|
63
|
-
|
69
|
+
# Fallback: check transactions directly:
|
70
|
+
|
71
|
+
# Look up all transactions in the credit wallet for a purchase.
|
72
|
+
transactions = credit_wallet&.transactions&.where(category: "credit_pack_purchase")
|
73
|
+
return false unless transactions.present?
|
74
|
+
|
75
|
+
begin
|
76
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
77
|
+
if adapter.include?("postgres")
|
78
|
+
# PostgreSQL supports the @> JSON containment operator.
|
79
|
+
transactions.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
|
80
|
+
else
|
81
|
+
# For other adapters (e.g. SQLite, MySQL), try using JSON_EXTRACT.
|
82
|
+
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, true])
|
83
|
+
end
|
84
|
+
rescue ActiveRecord::StatementInvalid
|
85
|
+
# If the SQL query fails (for example, if JSON_EXTRACT isn’t supported),
|
86
|
+
# fall back to loading transactions in Ruby and filtering them.
|
87
|
+
transactions.any? do |tx|
|
88
|
+
data =
|
89
|
+
if tx.metadata.is_a?(Hash)
|
90
|
+
tx.metadata
|
91
|
+
else
|
92
|
+
JSON.parse(tx.metadata) rescue {}
|
93
|
+
end
|
94
|
+
data["purchase_charge_id"].to_i == id.to_i && data["credits_fulfilled"].to_s == "true"
|
95
|
+
end
|
96
|
+
end
|
64
97
|
end
|
65
98
|
|
66
99
|
def fulfill_credit_pack!
|
@@ -132,8 +165,28 @@ module UsageCredits
|
|
132
165
|
|
133
166
|
def credits_already_refunded?
|
134
167
|
# Check if refund was already processed with credits deducted by looking for a refund transaction
|
135
|
-
|
136
|
-
|
168
|
+
# Look up transactions for a refund.
|
169
|
+
transactions = credit_wallet&.transactions&.where(category: "credit_pack_refund")
|
170
|
+
return false unless transactions.present?
|
171
|
+
|
172
|
+
begin
|
173
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
174
|
+
if adapter.include?("postgres")
|
175
|
+
transactions.exists?(['metadata @> ?', { refunded_purchase_charge_id: id, credits_refunded: true }.to_json])
|
176
|
+
else
|
177
|
+
transactions.exists?(["json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?", id, true])
|
178
|
+
end
|
179
|
+
rescue ActiveRecord::StatementInvalid
|
180
|
+
transactions.any? do |tx|
|
181
|
+
data =
|
182
|
+
if tx.metadata.is_a?(Hash)
|
183
|
+
tx.metadata
|
184
|
+
else
|
185
|
+
JSON.parse(tx.metadata) rescue {}
|
186
|
+
end
|
187
|
+
data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true"
|
188
|
+
end
|
189
|
+
end
|
137
190
|
end
|
138
191
|
|
139
192
|
def handle_refund!
|
@@ -19,7 +19,8 @@ module UsageCredits
|
|
19
19
|
CATEGORIES = [
|
20
20
|
# Bonus credits
|
21
21
|
"signup_bonus", # Initial signup bonus
|
22
|
-
"referral_bonus", # Referral reward
|
22
|
+
"referral_bonus", # Referral reward bonus
|
23
|
+
"bonus", # Generic bonus
|
23
24
|
|
24
25
|
# Subscription-related
|
25
26
|
"subscription_credits", # Generic subscription credits
|
@@ -19,6 +19,9 @@ module UsageCredits
|
|
19
19
|
belongs_to :owner, polymorphic: true
|
20
20
|
has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy
|
21
21
|
has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy
|
22
|
+
has_many :outbound_allocations, through: :transactions, source: :outgoing_allocations
|
23
|
+
has_many :inbound_allocations, through: :transactions, source: :incoming_allocations
|
24
|
+
has_many :allocations, ->(wallet) { unscope(:where).where("usage_credits_allocations.transaction_id IN (?) OR usage_credits_allocations.source_transaction_id IN (?)", wallet.transaction_ids, wallet.transaction_ids) }, class_name: "UsageCredits::Allocation", dependent: :destroy
|
22
25
|
|
23
26
|
validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
|
24
27
|
|
@@ -116,23 +119,29 @@ module UsageCredits
|
|
116
119
|
raise e
|
117
120
|
end
|
118
121
|
|
119
|
-
# Give credits to the wallet
|
122
|
+
# Give credits to the wallet with optional reason and expiration date
|
120
123
|
# @param amount [Integer] Number of credits to give
|
121
|
-
# @param reason [String]
|
122
|
-
|
123
|
-
|
124
|
-
raise ArgumentError, "
|
124
|
+
# @param reason [String, nil] Optional reason for giving credits (for auditing / trail purposes)
|
125
|
+
# @param expires_at [DateTime, nil] Optional expiration date for the credits
|
126
|
+
def give_credits(amount, reason: nil, expires_at: nil)
|
127
|
+
raise ArgumentError, "Amount is required" if amount.nil?
|
128
|
+
raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative?
|
129
|
+
raise ArgumentError, "Credit amount must be a whole number" unless amount.to_i.integer?
|
130
|
+
raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime)
|
131
|
+
raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current
|
125
132
|
|
126
133
|
category = case reason&.to_s
|
127
134
|
when "signup" then :signup_bonus
|
128
135
|
when "referral" then :referral_bonus
|
136
|
+
when /bonus/i then :bonus
|
129
137
|
else :manual_adjustment
|
130
138
|
end
|
131
139
|
|
132
140
|
add_credits(
|
133
|
-
amount,
|
141
|
+
amount.to_i,
|
134
142
|
metadata: { reason: reason },
|
135
|
-
category: category
|
143
|
+
category: category,
|
144
|
+
expires_at: expires_at
|
136
145
|
)
|
137
146
|
end
|
138
147
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: usage_credits
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- rameerez
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-02-
|
11
|
+
date: 2025-02-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|