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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 746f3023cb2533204e1eb089718a09019786f868a76cfa58c61fce459d608985
4
- data.tar.gz: cca4bb0ac339d37151f84a2270e7c34f436f6f66cdfbb593e8fbdb57aa7cefcd
3
+ metadata.gz: '09e65a66af34a0f2bf35a72d59fdd2d9d9799aaeaa1ac8b7628f222f65e5beab'
4
+ data.tar.gz: 2dc466a29bc36385aeee7c8bbb77833e521c2207e84ac6cf00543253d02af74e
5
5
  SHA512:
6
- metadata.gz: 539023a63628718b2a44628abb725ab6a1d23f115ca509fbb4cff5284f296cc77e71ae28d0fe0dd84725f415b984757973828214b190da5ba48be0f633778092
7
- data.tar.gz: 036544fb6fe39eb762216fef0f9a56cc41c31f0ff5eeb53156a47252a5948b473146b8ead147d5a27e98be652d5cac238f27b503747811064e9f83a2815522e2
6
+ metadata.gz: c1a6c0fc9dab7e315d67b6071b38d5e00b386bc21830438636405296b32fbabb510f0f31a77d995ea8d03972b939ebcf25aba3baa7f03847a66d2ee55b4a6a62
7
+ data.tar.gz: fd4c0225c47dfca3903249b9456d428c9012060acb4f8beceb4c577e5673a4f650cdfd0ba74035bae5b253284e8ccb73eeb87a11b815fed4b9626f00968bf84a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
- ## [Unreleased]
1
+ ## [0.1.1] - 2025-01-14
2
2
 
3
- ## [0.1.0] - 2025-01-18
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]
@@ -35,7 +35,7 @@ module UsageCredits
35
35
  end
36
36
 
37
37
  # Returns all active subscriptions as CreditSubscriptionPlan objects
38
- def subscriptions
38
+ def credit_subscriptions
39
39
  return [] unless credit_wallet
40
40
 
41
41
  credit_wallet.fulfillments
@@ -17,10 +17,18 @@ module UsageCredits
17
17
  end
18
18
 
19
19
  def succeeded?
20
- return true if data["status"] == "succeeded" || data[:status] == "succeeded"
21
- # For Stripe charges, a successful charge has amount_captured equal to the charge amount
22
- return true if type == "Pay::Stripe::Charge" && data["amount_captured"] == amount
23
- false
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
- credit_wallet&.transactions&.where(category: "credit_pack_purchase")
63
- .exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
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
- credit_wallet&.transactions&.where(category: "credit_pack_refund")
136
- .exists?(['metadata @> ?', { refunded_purchase_charge_id: id, credits_refunded: true }.to_json])
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] Why credits were given (for auditing)
122
- def give_credits(amount, reason: nil)
123
- raise ArgumentError, "Cannot give negative credits" if amount.negative?
124
- raise ArgumentError, "Credit amount must be a whole number" unless amount.integer?
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
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.0
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-12 00:00:00.000000000 Z
11
+ date: 2025-02-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails