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
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,30 @@ subscription_plan :pro do
end
```

### Upgrades, downgrades, and plan changes

`usage_credits` reacts to plan changes (via the `pay` gem), and we handle automatically credit issuing for upgrades & downgrades:

- **Upgrades**: credits are granted immediately for the new plan. If the new plan expires credits, upgrade credits expire too.
- **Downgrades**: scheduled for the end of the current period; users keep current benefits until then.
- **Non-credit plan changes**: moving from credit β†’ non-credit stops fulfillment at period end (no clawback).
- **Reactivation**: moving back to a credit plan reactivates fulfillment and grants credits (no signup bonus on reactivation).
- **Pending downgrades**: if a user returns to their current plan before the downgrade takes effect, we cancel the pending change and do **not** grant extra credits.
- **Credit gaming prevention**: we take measures to protect against user gaming the credit system by repeatedly upgrading/downgrading their subscription.

This happens automatically thanks to our Pay Subscription extension (changes to the `Subscription` model in the `pay` gem trigger `usage_credits` issuing the right credits based on the subscription change)

### What we handle vs. what we don't (brief)

Handled:
- Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
- Pending downgrade application on renewal
- Credit expiration and rollover

Not handled (yet):
- Plan changes while **trialing** (we only handle `status == "active"`)
- Paused subscriptions (see TODO in code)

## Transaction history & audit trail

Every transaction (whether adding or deducting credits) is logged in the ledger, and automatically tracked with metadata:
Expand Down Expand Up @@ -564,9 +588,7 @@ Real billing systems usually find edge cases when handling things like:
Please help us by contributing to add tests to cover all critical paths!

## TODO

- [ ] Write a comprehensive `minitest` test suite that covers all critical paths (both happy paths and weird edge cases)
- [ ] Handle subscription upgrades and downgrades (upgrade immediately; downgrade at end of billing period? Cover all scenarios allowed by the Stripe Customer Portal?)
No open TODOs here right now. If you find an edge case, please open an issue or PR.

## Testing

Expand Down
2 changes: 1 addition & 1 deletion lib/usage_credits/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def initialize
# This ensures smooth transition between fulfillment periods.
# For this amount of time, old, already expired credits will be erroneously counted as available in the user's balance.
# Keep it short enough that users don't notice they have the last period's credits still available, but
# long enough that there's a smooth transition and users never get zero credits in between fullfillment periods
# long enough that there's a smooth transition and users never get zero credits in between fulfillment periods
# A good setting is to match the frequency of your UsageCredits::FulfillmentJob runs
@fulfillment_grace_period = 5.minutes # If you run your fulfillment job every 5 minutes, this should be enough

Expand Down
350 changes: 317 additions & 33 deletions lib/usage_credits/models/concerns/pay_subscription_extension.rb

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lib/usage_credits/models/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Transaction < ApplicationRecord
"subscription_credits", # Generic subscription credits
"subscription_trial", # Trial period credits
"subscription_signup_bonus", # Bonus for subscribing
"subscription_upgrade", # Plan upgrade credits

# One-time purchases
"credit_pack", # Generic credit pack
Expand Down
5 changes: 3 additions & 2 deletions lib/usage_credits/models/wallet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ def spend_credits_on(operation_name, **params)
raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)

# Create audit trail
audit_data = operation.to_audit_hash(params)
# Stringify keys from audit_data to avoid duplicate key warnings in JSON
audit_data = operation.to_audit_hash(params).deep_stringify_keys
deduct_params = {
metadata: audit_data.merge(operation.metadata).merge(
metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge(
"executed_at" => Time.current,
"gem_version" => UsageCredits::VERSION
),
Expand Down
11 changes: 6 additions & 5 deletions lib/usage_credits/services/fulfillment_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,16 @@ def fulfillment_category
end

def fulfillment_metadata
# Use string keys consistently to avoid duplicates after JSON serialization
base_metadata = {
last_fulfilled_at: Time.current,
reason: "fulfillment_cycle",
fulfillment_period: @fulfillment.fulfillment_period,
fulfillment_id: @fulfillment.id
"last_fulfilled_at" => Time.current,
"reason" => "fulfillment_cycle",
"fulfillment_period" => @fulfillment.fulfillment_period,
"fulfillment_id" => @fulfillment.id
}

if @fulfillment.source.is_a?(Pay::Subscription)
base_metadata[:subscription_id] = @fulfillment.source.id
base_metadata["subscription_id"] = @fulfillment.source.id
end

@fulfillment.metadata.merge(base_metadata)
Expand Down
8 changes: 7 additions & 1 deletion test/dummy/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ PATH
remote: ../..
specs:
usage_credits (0.1.1)
pay (~> 8.3)
pay (>= 8.3, < 12.0)
rails (>= 6.1)

GEM
Expand Down Expand Up @@ -321,13 +321,19 @@ DEPENDENCIES
awesome_print
bootsnap
brakeman
bundler (~> 2.0)
debug
importmap-rails
jbuilder
kamal
minitest (~> 5.0)
propshaft
puma (>= 5.0)
rails (~> 8.0.1)
rake (~> 13.0)
rubocop (~> 1.0)
rubocop-minitest (~> 0.35)
rubocop-performance (~> 1.0)
rubocop-rails-omakase
solid_cable
solid_cache
Expand Down
Loading