Skip to content

Commit a0a095a

Browse files
authored
Merge pull request #20 from rameerez/feature/handle-subscription-plan-changes
Handle subscription plan changes (upgrades & downgrades)
2 parents 2b2fc4e + c2bc00a commit a0a095a

File tree

8 files changed

+2590
-45
lines changed

8 files changed

+2590
-45
lines changed

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,30 @@ subscription_plan :pro do
415415
end
416416
```
417417

418+
### Upgrades, downgrades, and plan changes
419+
420+
`usage_credits` reacts to plan changes (via the `pay` gem), and we handle automatically credit issuing for upgrades & downgrades:
421+
422+
- **Upgrades**: credits are granted immediately for the new plan. If the new plan expires credits, upgrade credits expire too.
423+
- **Downgrades**: scheduled for the end of the current period; users keep current benefits until then.
424+
- **Non-credit plan changes**: moving from credit → non-credit stops fulfillment at period end (no clawback).
425+
- **Reactivation**: moving back to a credit plan reactivates fulfillment and grants credits (no signup bonus on reactivation).
426+
- **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.
427+
- **Credit gaming prevention**: we take measures to protect against user gaming the credit system by repeatedly upgrading/downgrading their subscription.
428+
429+
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)
430+
431+
### What we handle vs. what we don't (brief)
432+
433+
Handled:
434+
- Subscription create, renew, cancel, upgrade, downgrade, non-credit transitions
435+
- Pending downgrade application on renewal
436+
- Credit expiration and rollover
437+
438+
Not handled (yet):
439+
- Plan changes while **trialing** (we only handle `status == "active"`)
440+
- Paused subscriptions (see TODO in code)
441+
418442
## Transaction history & audit trail
419443

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

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

571593
## Testing
572594

lib/usage_credits/configuration.rb

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

lib/usage_credits/models/concerns/pay_subscription_extension.rb

Lines changed: 317 additions & 33 deletions
Large diffs are not rendered by default.

lib/usage_credits/models/transaction.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Transaction < ApplicationRecord
2626
"subscription_credits", # Generic subscription credits
2727
"subscription_trial", # Trial period credits
2828
"subscription_signup_bonus", # Bonus for subscribing
29+
"subscription_upgrade", # Plan upgrade credits
2930

3031
# One-time purchases
3132
"credit_pack", # Generic credit pack

lib/usage_credits/models/wallet.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,10 @@ def spend_credits_on(operation_name, **params)
9494
raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)
9595

9696
# Create audit trail
97-
audit_data = operation.to_audit_hash(params)
97+
# Stringify keys from audit_data to avoid duplicate key warnings in JSON
98+
audit_data = operation.to_audit_hash(params).deep_stringify_keys
9899
deduct_params = {
99-
metadata: audit_data.merge(operation.metadata).merge(
100+
metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge(
100101
"executed_at" => Time.current,
101102
"gem_version" => UsageCredits::VERSION
102103
),

lib/usage_credits/services/fulfillment_service.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,16 @@ def fulfillment_category
112112
end
113113

114114
def fulfillment_metadata
115+
# Use string keys consistently to avoid duplicates after JSON serialization
115116
base_metadata = {
116-
last_fulfilled_at: Time.current,
117-
reason: "fulfillment_cycle",
118-
fulfillment_period: @fulfillment.fulfillment_period,
119-
fulfillment_id: @fulfillment.id
117+
"last_fulfilled_at" => Time.current,
118+
"reason" => "fulfillment_cycle",
119+
"fulfillment_period" => @fulfillment.fulfillment_period,
120+
"fulfillment_id" => @fulfillment.id
120121
}
121122

122123
if @fulfillment.source.is_a?(Pay::Subscription)
123-
base_metadata[:subscription_id] = @fulfillment.source.id
124+
base_metadata["subscription_id"] = @fulfillment.source.id
124125
end
125126

126127
@fulfillment.metadata.merge(base_metadata)

test/dummy/Gemfile.lock

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ PATH
22
remote: ../..
33
specs:
44
usage_credits (0.1.1)
5-
pay (~> 8.3)
5+
pay (>= 8.3, < 12.0)
66
rails (>= 6.1)
77

88
GEM
@@ -321,13 +321,19 @@ DEPENDENCIES
321321
awesome_print
322322
bootsnap
323323
brakeman
324+
bundler (~> 2.0)
324325
debug
325326
importmap-rails
326327
jbuilder
327328
kamal
329+
minitest (~> 5.0)
328330
propshaft
329331
puma (>= 5.0)
330332
rails (~> 8.0.1)
333+
rake (~> 13.0)
334+
rubocop (~> 1.0)
335+
rubocop-minitest (~> 0.35)
336+
rubocop-performance (~> 1.0)
331337
rubocop-rails-omakase
332338
solid_cable
333339
solid_cache

0 commit comments

Comments
 (0)