Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ process_image(params) # If this fails, credits are already spent!
> If validation fails (e.g., file too large), both methods will raise `InvalidOperation`.
> Perform your operation inside the `spend_credits_on` block OR make the credit spend conditional to the actual operation, so users are not charged if the operation fails.

## Low balance alerts
## Low balance alerts (deprecated)

> [!WARNING]
> Use lifecycle callbacks (below) instead of this old deprecated approach.

You can hook on to our low balance event to notify users when they are running low on credits (useful to upsell them a credit pack):

Expand All @@ -286,6 +289,76 @@ UsageCredits.configure do |config|
end
```

## Lifecycle callbacks

You can hook into credit events for analytics, audit logging, notifications, or custom business logic:

```ruby
UsageCredits.configure do |config|
# Prompt user to buy more when running low on credits (useful to upsell them a credit pack)
config.on_low_balance_reached do |ctx|
LowCreditsMailer.buy_more(ctx.owner, remaining: ctx.new_balance).deliver_later
end

# Prompt again when they run out
config.on_balance_depleted do |ctx|
OutOfCreditsMailer.buy_more(ctx.owner).deliver_later
end

# Log failed operations (useful for debugging)
config.on_insufficient_credits do |ctx|
Rails.logger.info "[Credits] User #{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
end

# Track purchases in your analytics (Mixpanel, Amplitude, Segment, etc.)
config.on_credit_pack_purchased do |ctx|
# Replace with your analytics service
Rails.logger.info "[Credits] User #{ctx.owner.id} purchased #{ctx.amount} credits"
end

# Audit trail for credit changes
config.on_credits_deducted do |ctx|
Rails.logger.info "[Credits] User #{ctx.owner.id} spent #{ctx.amount}, balance: #{ctx.new_balance}"
end
end
```

> [!IMPORTANT]
> Callbacks get executed every single time the action happens (duh) so you don't want to do heavy operations there, or else your app could become extremely slow. Keep callbacks fast: one good option is using the callback to enqueue background jobs (`deliver_later`, `perform_later`) to avoid blocking credit operations.

Available callbacks:
- `on_credits_added`: After credits are added to a wallet
- `on_credits_deducted`: After credits are deducted from a wallet
- `on_low_balance_reached`: When balance drops below threshold (fires once per crossing)
- `on_balance_depleted`: When balance reaches exactly zero
- `on_insufficient_credits`: When an operation fails due to insufficient credits
- `on_credit_pack_purchased`: After a credit pack purchase is fulfilled
- `on_subscription_credits_awarded`: After subscription credits are awarded

All callbacks receive a context object (`ctx`). Available fields vary by event:

| Field | `credits_added` | `credits_deducted` | `low_balance_reached` | `balance_depleted` | `insufficient_credits` | `credit_pack_purchased` | `subscription_credits_awarded` |
|-------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| `owner` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `wallet` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| `amount` | ✓ | ✓ | | | ✓ | ✓ | ✓ |
| `previous_balance` | ✓ | ✓ | ✓ | ✓ | | | |
| `new_balance` | ✓ | ✓ | ✓ | ✓ | | | |
| `transaction` | ✓ | ✓ | | | | ✓ | ✓ |
| `category` | ✓ | ✓ | | | | | |
| `threshold` | | | ✓ | | | | |
| `operation_name` | | | | | ✓ | | |
| `metadata` | ✓ | ✓ | | | ✓ | ✓ | ✓ |

**Metadata contents:**
- `insufficient_credits`: `{ available:, required:, params: }`
- `credit_pack_purchased`: `{ credit_pack_name:, credit_pack:, pay_charge:, price_cents: }`
- `subscription_credits_awarded`: `{ subscription_plan_name:, subscription:, pay_subscription:, fulfillment_period: }`

All contexts support `ctx.to_h` to convert to a hash (excludes nil values).

Callbacks are isolated, so errors in callbacks won't break credit operations.

## Award bonus credits

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.
Expand Down
58 changes: 55 additions & 3 deletions lib/generators/usage_credits/templates/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,61 @@
#
# Handle low credit balance alerts – Useful to sell booster credit packs, for example
#
# config.on_low_balance do |user|
# Send notification to user when their balance drops below the threshold
# UserMailer.low_credits_alert(user).deliver_later
# config.on_low_balance do |owner|
# # Send notification to user when their balance drops below the threshold
# UserMailer.low_credits_alert(owner).deliver_later
# end
#
#
# === Lifecycle Callbacks ===
#
# Hook into credit events for analytics, notifications, and custom logic.
# All callbacks receive a context object with event-specific data.
#
# Available callbacks:
# on_credits_added - After credits are added to a wallet
# on_credits_deducted - After credits are deducted from a wallet
# on_low_balance_reached - When balance drops below threshold (fires once per crossing)
# on_balance_depleted - When balance reaches exactly zero
# on_insufficient_credits - When an operation fails due to insufficient credits
# on_credit_pack_purchased - After a credit pack purchase is fulfilled
# on_subscription_credits_awarded - After subscription credits are awarded
#
# Context object properties (available depending on event):
# ctx.event # Symbol - the event name
# ctx.owner # The wallet owner (User, Team, etc.)
# ctx.wallet # The UsageCredits::Wallet instance
# ctx.amount # Credits involved
# ctx.previous_balance # Balance before the operation
# ctx.new_balance # Balance after the operation
# ctx.transaction # The UsageCredits::Transaction record
# ctx.category # Transaction category (:manual_adjustment, :operation_charge, etc.)
# ctx.threshold # Low balance threshold (for low_balance_reached)
# ctx.operation_name # Operation name (for insufficient_credits)
# ctx.metadata # Additional context-specific data
# ctx.to_h # Convert to hash (excludes nil values)
#
# IMPORTANT: Keep callbacks fast! Use background jobs (deliver_later, perform_later) to avoid blocking credit operations.
#
# Example: Prompt user to buy more credits when running low:
# config.on_low_balance_reached do |ctx|
# LowCreditsMailer.buy_more(ctx.owner, remaining: ctx.new_balance).deliver_later
# end
#
# Example: Prompt user to buy credits when they run out:
# config.on_balance_depleted do |ctx|
# OutOfCreditsMailer.buy_more(ctx.owner).deliver_later
# end
#
# Example: Log when users hit credit limits (useful for debugging)
# config.on_insufficient_credits do |ctx|
# Rails.logger.info "[Credits] User #{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
# end
#
# Example: Track credit purchases (replace with your analytics service)
# config.on_credit_pack_purchased do |ctx|
# # e.g., Mixpanel, Amplitude, Segment, PostHog, etc.
# YourAnalyticsService.track(ctx.owner.id, "credits_purchased", amount: ctx.amount)
# end
#
#
Expand Down
21 changes: 19 additions & 2 deletions lib/usage_credits.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
# 4. Core functionality
require "usage_credits/version"
require "usage_credits/configuration" # Single source of truth for all configuration in this gem
require "usage_credits/callback_context" # Struct for callback event data
require "usage_credits/callbacks" # Callback dispatch module

# 5. Shim Rails classes so requires don't break
module UsageCredits
Expand Down Expand Up @@ -76,6 +78,7 @@ def configure
# Reset configuration to defaults (mainly for testing)
def reset!
@configuration = nil
@deprecation_warnings = {}
end

# DSL methods - all delegate to configuration
Expand Down Expand Up @@ -128,16 +131,30 @@ def find_subscription_plan_by_processor_id(processor_id)
end
alias_method :find_plan_by_id, :find_subscription_plan_by_processor_id

# Event handling for low balance notifications
# DEPRECATED: Event handling for low balance notifications
# Use on_low_balance_reached callback instead
# This method shows a deprecation warning only once to avoid log spam
def notify_low_balance(owner)
@deprecation_warnings ||= {}
unless @deprecation_warnings[:notify_low_balance]
warn "[DEPRECATION] UsageCredits.notify_low_balance is deprecated. Use on_low_balance_reached callback instead."
@deprecation_warnings[:notify_low_balance] = true
end
return unless configuration.low_balance_callback
configuration.low_balance_callback.call(owner)
end

# DEPRECATED: Events are now dispatched through Callbacks module
# This method shows a deprecation warning only once to avoid log spam
def handle_event(event, **params)
@deprecation_warnings ||= {}
unless @deprecation_warnings[:handle_event]
warn "[DEPRECATION] UsageCredits.handle_event is deprecated. Events are now dispatched through the Callbacks module."
@deprecation_warnings[:handle_event] = true
end
case event
when :low_balance_reached
notify_low_balance(params[:wallet].owner)
notify_low_balance(params[:wallet]&.owner)
end
end

Expand Down
28 changes: 28 additions & 0 deletions lib/usage_credits/callback_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module UsageCredits
# Immutable context object passed to all callbacks
# Provides consistent, typed access to event data
CallbackContext = Struct.new(
:event, # Symbol - the event type
:wallet, # UsageCredits::Wallet instance
:amount, # Integer - credits involved (if applicable)
:previous_balance, # Integer - balance before operation
:new_balance, # Integer - balance after operation
:threshold, # Integer - low balance threshold (for low_balance events)
:category, # Symbol - transaction category
:operation_name, # Symbol - name of the operation
:transaction, # UsageCredits::Transaction - the transaction created
:metadata, # Hash - additional contextual data
keyword_init: true
) do
def to_h
super.compact
end

# Convenience: get owner from wallet
def owner
wallet&.owner
end
end
end
67 changes: 67 additions & 0 deletions lib/usage_credits/callbacks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

module UsageCredits
# Centralized callback dispatch module
# Handles executing callbacks with error isolation
module Callbacks
module_function

# Dispatch a callback event with error isolation
# Callbacks should never break the main operation
#
# @param event [Symbol] The event type (e.g., :credits_added, :low_balance_reached)
# @param context_data [Hash] Data to pass to the callback via CallbackContext
def dispatch(event, **context_data)
config = UsageCredits.configuration
callback = config.public_send(:"on_#{event}_callback")

return unless callback.is_a?(Proc)

context = CallbackContext.new(event: event, **context_data)

execute_safely(callback, context)
end

# Execute callback with error isolation and arity handling
#
# @param callback [Proc] The callback to execute
# @param context [CallbackContext] The context to pass
def execute_safely(callback, context)
case callback.arity
when 1, -1, -2 # Accepts one arg or variable args
callback.call(context)
when 0
callback.call
else
log_warn "[UsageCredits] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
end
rescue StandardError => e
# Log but don't re-raise - callbacks should never break credit operations
log_error "[UsageCredits] Callback error for #{context.event}: #{e.class}: #{e.message}"
log_debug e.backtrace.join("\n")
end

# Safe logging that works with or without Rails
def log_error(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.error(message)
else
warn message
end
end

def log_warn(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.warn(message)
else
warn message
end
end

def log_debug(message)
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
Rails.logger.debug(message)
end
end
end
end
68 changes: 67 additions & 1 deletion lib/usage_credits/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,18 @@ class Configuration

attr_reader :low_balance_callback

# =========================================
# Lifecycle Callbacks
# =========================================

attr_reader :on_credits_added_callback,
:on_credits_deducted_callback,
:on_low_balance_reached_callback,
:on_balance_depleted_callback,
:on_insufficient_credits_callback,
:on_subscription_credits_awarded_callback,
:on_credit_pack_purchased_callback

def initialize
# Initialize empty data stores
@operations = {} # Credit-consuming operations (e.g., "send_email: 1 credit")
Expand All @@ -71,6 +83,15 @@ def initialize
@allow_negative_balance = false
@low_balance_threshold = nil
@low_balance_callback = nil # Called when user hits low_balance_threshold

# Lifecycle callbacks (all nil by default)
@on_credits_added_callback = nil
@on_credits_deducted_callback = nil
@on_low_balance_reached_callback = nil
@on_balance_depleted_callback = nil
@on_insufficient_credits_callback = nil
@on_subscription_credits_awarded_callback = nil
@on_credit_pack_purchased_callback = nil
end

# =========================================
Expand Down Expand Up @@ -189,10 +210,55 @@ def format_credits(&block)
@credit_formatter = block
end

# Set what happens when credits are low
# =========================================
# Lifecycle Callback DSL Methods
# =========================================
# All methods allow nil block to clear the callback (useful for testing)

# Called after credits are added to a wallet
def on_credits_added(&block)
@on_credits_added_callback = block
end

# Called after credits are deducted from a wallet
def on_credits_deducted(&block)
@on_credits_deducted_callback = block
end

# Called when balance crosses below the low_balance_threshold
# Receives CallbackContext with full event data
def on_low_balance_reached(&block)
@on_low_balance_reached_callback = block
end

# Called when balance reaches exactly zero
def on_balance_depleted(&block)
@on_balance_depleted_callback = block
end

# Called when an operation fails due to insufficient credits
def on_insufficient_credits(&block)
@on_insufficient_credits_callback = block
end

# Called after subscription credits are awarded
def on_subscription_credits_awarded(&block)
@on_subscription_credits_awarded_callback = block
end

# Called after a credit pack is purchased
def on_credit_pack_purchased(&block)
@on_credit_pack_purchased_callback = block
end

# BACKWARD COMPATIBILITY: Legacy method that receives owner, not context
# Existing users' code: config.on_low_balance { |owner| ... }
def on_low_balance(&block)
raise ArgumentError, "Block is required for low balance callback" unless block_given?
# Store legacy callback as before (for backward compat with direct calls)
@low_balance_callback = block
# Also create a wrapper for new callback system that extracts owner from context
@on_low_balance_reached_callback = ->(ctx) { block.call(ctx.owner) }
end

# =========================================
Expand Down
Loading
Loading