usage_credits 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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