usage_credits 0.1.0
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 +7 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +559 -0
- data/Rakefile +32 -0
- data/lib/generators/usage_credits/install_generator.rb +49 -0
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
- data/lib/generators/usage_credits/templates/initializer.rb +105 -0
- data/lib/usage_credits/configuration.rb +204 -0
- data/lib/usage_credits/core_ext/numeric.rb +59 -0
- data/lib/usage_credits/cost/base.rb +43 -0
- data/lib/usage_credits/cost/compound.rb +37 -0
- data/lib/usage_credits/cost/fixed.rb +34 -0
- data/lib/usage_credits/cost/variable.rb +42 -0
- data/lib/usage_credits/engine.rb +37 -0
- data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
- data/lib/usage_credits/helpers/credits_helper.rb +45 -0
- data/lib/usage_credits/helpers/period_parser.rb +77 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
- data/lib/usage_credits/models/allocation.rb +31 -0
- data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
- data/lib/usage_credits/models/credit_pack.rb +159 -0
- data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
- data/lib/usage_credits/models/fulfillment.rb +91 -0
- data/lib/usage_credits/models/operation.rb +153 -0
- data/lib/usage_credits/models/transaction.rb +174 -0
- data/lib/usage_credits/models/wallet.rb +310 -0
- data/lib/usage_credits/railtie.rb +17 -0
- data/lib/usage_credits/services/fulfillment_service.rb +129 -0
- data/lib/usage_credits/version.rb +5 -0
- data/lib/usage_credits.rb +170 -0
- data/sig/usagecredits.rbs +4 -0
- metadata +115 -0
@@ -0,0 +1,251 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# Extension to Pay::Subscription to refill user credits
|
5
|
+
# (and/or set up the `Fulfillment` object that the `UsageCredits::FulfillmentJob` will pick up to refill periodically)
|
6
|
+
#
|
7
|
+
# We'll:
|
8
|
+
# 1) Immediately award trial or first-cycle credits on create
|
9
|
+
# 2) Create or update a Fulfillment record for future awarding (the fulfillment job will actually do fulfillment)
|
10
|
+
# 3) Expire leftover credits on cancellation if needed
|
11
|
+
#
|
12
|
+
# Explanation:
|
13
|
+
#
|
14
|
+
# `after_commit :handle_initial_award_and_fulfillment_setup, on: :create`
|
15
|
+
# If the subscription is trialing or active, do immediate awarding and create a Fulfillment for future recurring awarding.
|
16
|
+
#
|
17
|
+
# Fulfillment
|
18
|
+
# Has next_fulfillment_at set to (Time.current + 1.month), or whenever the first real billing cycle is.
|
19
|
+
#
|
20
|
+
# `update_fulfillment_on_cancellation`
|
21
|
+
# If the user cancels, we set fulfillment.stops_at = ends_at, so no further awarding is done.
|
22
|
+
# Optionally we can also forcibly expire leftover credits.
|
23
|
+
#
|
24
|
+
# That’s it. Everything else—like “monthly awarding,” “rollover credits,” etc.—should be handled by the
|
25
|
+
# `FulfillmentService#process` method, which checks the plan’s config to decide how many credits to add next time around.
|
26
|
+
|
27
|
+
# If the subscription is trialing or active, do immediate awarding and create a Fulfillment for future recurring awarding.
|
28
|
+
module PaySubscriptionExtension
|
29
|
+
extend ActiveSupport::Concern
|
30
|
+
|
31
|
+
included do
|
32
|
+
# For initial setup and fulfillment, we can't do after_create or on: :create because the subscription first may
|
33
|
+
# get created with status "incomplete" and only get updated to status "active" when the payment is cleared
|
34
|
+
after_commit :handle_initial_award_and_fulfillment_setup
|
35
|
+
|
36
|
+
after_commit :update_fulfillment_on_renewal, if: :subscription_renewed?
|
37
|
+
after_commit :update_fulfillment_on_cancellation, if: :subscription_canceled?
|
38
|
+
|
39
|
+
# TODO: handle plan changes (upgrades / downgrades)
|
40
|
+
# TODO: handle paused subscriptions (may still have an "active" status?)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Identify the usage_credits plan object
|
44
|
+
def credit_subscription_plan
|
45
|
+
@credit_subscription_plan ||= UsageCredits.configuration.find_subscription_plan_by_processor_id(processor_plan)
|
46
|
+
end
|
47
|
+
|
48
|
+
def provides_credits?
|
49
|
+
credit_subscription_plan.present?
|
50
|
+
end
|
51
|
+
|
52
|
+
def fullfillment_should_stop_at
|
53
|
+
(ends_at || current_period_end)
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
# Returns true if the subscription has a valid credit wallet to operate on
|
59
|
+
def has_valid_wallet?
|
60
|
+
return false unless customer&.owner&.respond_to?(:credit_wallet)
|
61
|
+
return false unless customer.owner.credit_wallet.present?
|
62
|
+
true
|
63
|
+
end
|
64
|
+
|
65
|
+
def credits_already_fulfilled?
|
66
|
+
# TODO: There's a race condition where Pay actually updates the subscription two times on initial creation,
|
67
|
+
# leading to us triggering handle_initial_award_and_fulfillment_setup twice too.
|
68
|
+
# Since no Fulfillment record has been created yet, both callbacks will try to create the same Fulfillment object
|
69
|
+
# at about the same time, thus making this check useless (there's nothing written to the DB yet)
|
70
|
+
# For now, we handle it by adding a validation to the Fulfillment model so that there's no two Fulfillment objects
|
71
|
+
# with the same source_id -- so whichever of the two callbacks gets processed first wins, the other just fails.
|
72
|
+
# That's how we prevent double credit awarding for now, but this race condition should be handled more elegantly.
|
73
|
+
UsageCredits::Fulfillment.exists?(source: self)
|
74
|
+
end
|
75
|
+
|
76
|
+
def subscription_renewed?
|
77
|
+
(saved_change_to_ends_at? || saved_change_to_current_period_end?) && status == "active"
|
78
|
+
end
|
79
|
+
|
80
|
+
# This doesn't get called the exact moment the user cancels its subscription, but at the end of the period,
|
81
|
+
# when the payment processor sends the event that the subscription has actually been cancelled.
|
82
|
+
# For the moment the user clicks on "Cancel subscription", the sub keeps its state as "active" (for now),
|
83
|
+
# the sub just gets its `ends_at` set from nil to the actual cancellation date.
|
84
|
+
def subscription_canceled?
|
85
|
+
saved_change_to_status? && status == "canceled"
|
86
|
+
end
|
87
|
+
|
88
|
+
# =========================================
|
89
|
+
# Actual fulfillment logic
|
90
|
+
# =========================================
|
91
|
+
|
92
|
+
# Immediate awarding of first cycle + set up Fulfillment object for subsequent periods
|
93
|
+
def handle_initial_award_and_fulfillment_setup
|
94
|
+
return unless provides_credits?
|
95
|
+
return unless has_valid_wallet?
|
96
|
+
|
97
|
+
# We only do immediate awarding if the subscription is trialing or active
|
98
|
+
return unless ["trialing", "active"].include?(status)
|
99
|
+
|
100
|
+
# We'll skip if we already have a fulfillment record
|
101
|
+
return if credits_already_fulfilled?
|
102
|
+
|
103
|
+
plan = credit_subscription_plan
|
104
|
+
wallet = customer.owner.credit_wallet
|
105
|
+
|
106
|
+
# Using the configured grace period
|
107
|
+
credits_expire_at = !plan.rollover_enabled ? (created_at + plan.parsed_fulfillment_period + UsageCredits.configuration.fulfillment_grace_period) : nil
|
108
|
+
|
109
|
+
Rails.logger.info "Fulfilling initial credits for subscription #{id}"
|
110
|
+
Rails.logger.info " Status: #{status}"
|
111
|
+
Rails.logger.info " Plan: #{plan}"
|
112
|
+
|
113
|
+
# Transaction for atomic awarding + fulfillment creation
|
114
|
+
ActiveRecord::Base.transaction do
|
115
|
+
|
116
|
+
total_credits_awarded = 0
|
117
|
+
transaction_ids = []
|
118
|
+
|
119
|
+
# 1) If this is a trial and not an active subscription: award trial credits, if any
|
120
|
+
if status == "trialing" && plan.trial_credits.positive?
|
121
|
+
|
122
|
+
# Immediate awarding of trial credits
|
123
|
+
transaction = wallet.add_credits(plan.trial_credits,
|
124
|
+
category: "subscription_trial",
|
125
|
+
expires_at: trial_ends_at,
|
126
|
+
metadata: {
|
127
|
+
subscription_id: id,
|
128
|
+
reason: "initial_trial_credits",
|
129
|
+
plan: processor_plan,
|
130
|
+
fulfilled_at: Time.current
|
131
|
+
}
|
132
|
+
)
|
133
|
+
transaction_ids << transaction.id
|
134
|
+
total_credits_awarded += plan.trial_credits
|
135
|
+
|
136
|
+
elsif status == "active"
|
137
|
+
|
138
|
+
# Awarding of signup bonus, if any
|
139
|
+
if plan.signup_bonus_credits.positive?
|
140
|
+
transaction = wallet.add_credits(plan.signup_bonus_credits,
|
141
|
+
category: "subscription_signup_bonus",
|
142
|
+
metadata: {
|
143
|
+
subscription_id: id,
|
144
|
+
reason: "signup_bonus",
|
145
|
+
plan: processor_plan,
|
146
|
+
fulfilled_at: Time.current
|
147
|
+
}
|
148
|
+
)
|
149
|
+
transaction_ids << transaction.id
|
150
|
+
total_credits_awarded += plan.signup_bonus_credits
|
151
|
+
end
|
152
|
+
|
153
|
+
# Actual awarding of the subscription credits
|
154
|
+
if plan.credits_per_period.positive?
|
155
|
+
transaction = wallet.add_credits(plan.credits_per_period,
|
156
|
+
category: "subscription_credits",
|
157
|
+
expires_at: credits_expire_at, # This will be nil if credit rollover is enabled
|
158
|
+
metadata: {
|
159
|
+
subscription_id: id,
|
160
|
+
reason: "first_cycle",
|
161
|
+
plan: processor_plan,
|
162
|
+
fulfilled_at: Time.current
|
163
|
+
}
|
164
|
+
)
|
165
|
+
transaction_ids << transaction.id
|
166
|
+
total_credits_awarded += plan.credits_per_period
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# 2) Create a Fulfillment record for subsequent awarding
|
171
|
+
# Use current_period_start as the base time, falling back to created_at
|
172
|
+
period_start = if trial_ends_at && status == "trialing"
|
173
|
+
trial_ends_at
|
174
|
+
else
|
175
|
+
current_period_start || created_at
|
176
|
+
end
|
177
|
+
|
178
|
+
# Ensure next_fulfillment_at is in the future
|
179
|
+
next_fulfillment_at = period_start + plan.parsed_fulfillment_period
|
180
|
+
next_fulfillment_at = Time.current + plan.parsed_fulfillment_period if next_fulfillment_at <= Time.current
|
181
|
+
|
182
|
+
fulfillment = UsageCredits::Fulfillment.create!(
|
183
|
+
wallet: wallet,
|
184
|
+
source: self,
|
185
|
+
fulfillment_type: "subscription",
|
186
|
+
credits_last_fulfillment: total_credits_awarded,
|
187
|
+
fulfillment_period: plan.fulfillment_period_display,
|
188
|
+
last_fulfilled_at: Time.now,
|
189
|
+
next_fulfillment_at: next_fulfillment_at,
|
190
|
+
stops_at: fullfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation)
|
191
|
+
metadata: {
|
192
|
+
subscription_id: id,
|
193
|
+
plan: processor_plan,
|
194
|
+
}
|
195
|
+
)
|
196
|
+
|
197
|
+
# Link created transactions to the fulfillment object for traceability
|
198
|
+
UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment.id)
|
199
|
+
|
200
|
+
Rails.logger.info "Initial fulfillment for subscription #{id} finished. Created fulfillment #{fulfillment.id}"
|
201
|
+
rescue => e
|
202
|
+
Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
|
203
|
+
raise ActiveRecord::Rollback
|
204
|
+
end
|
205
|
+
end
|
206
|
+
|
207
|
+
# Handle subscription renewal (we received a new payment for another billing period)
|
208
|
+
# Each time the subscription renews and ends_at moves forward,
|
209
|
+
# we keep awarding credits because Fulfillment#stops_at also moves forward
|
210
|
+
def update_fulfillment_on_renewal
|
211
|
+
return unless provides_credits? && has_valid_wallet?
|
212
|
+
|
213
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
214
|
+
return unless fulfillment
|
215
|
+
|
216
|
+
ActiveRecord::Base.transaction do
|
217
|
+
# Subscription renewed, we can set the new Fulfillment stops_at to the extended date
|
218
|
+
fulfillment.update!(stops_at: fullfillment_should_stop_at)
|
219
|
+
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at updated to #{fulfillment.stops_at}"
|
220
|
+
rescue => e
|
221
|
+
Rails.logger.error "Failed to extend fulfillment period for subscription #{id}: #{e.message}"
|
222
|
+
raise ActiveRecord::Rollback
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
|
227
|
+
# If the subscription is canceled, let's set the Fulfillment's stops_at so that the job won't keep awarding
|
228
|
+
def update_fulfillment_on_cancellation
|
229
|
+
plan = credit_subscription_plan
|
230
|
+
return unless plan && has_valid_wallet?
|
231
|
+
|
232
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
233
|
+
return unless fulfillment
|
234
|
+
|
235
|
+
ActiveRecord::Base.transaction do
|
236
|
+
# Subscription cancelled, so stop awarding credits in the future
|
237
|
+
fulfillment.update!(stops_at: fullfillment_should_stop_at)
|
238
|
+
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at set to #{fulfillment.stops_at} due to cancellation"
|
239
|
+
rescue => e
|
240
|
+
Rails.logger.error "Failed to stop credit fulfillment for subscription #{id}: #{e.message}"
|
241
|
+
raise ActiveRecord::Rollback
|
242
|
+
end
|
243
|
+
|
244
|
+
# TODO: we can also expire already awarded credits here (without making the ledger mutable – we'll need to
|
245
|
+
# check if the plan expires credits or not, and if rollover we may need to add a negative transaction to offset
|
246
|
+
# the remaining balance)
|
247
|
+
|
248
|
+
end
|
249
|
+
|
250
|
+
end
|
251
|
+
end
|
@@ -0,0 +1,159 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# A DSL to define credit packs that users can buy as one-time purchases.
|
5
|
+
#
|
6
|
+
# Credit packs can be purchased independently and separately from any subscription.
|
7
|
+
#
|
8
|
+
# The actual credit fulfillment is handled by the PayChargeExtension.
|
9
|
+
#
|
10
|
+
# @see PayChargeExtension for the actual payment processing, credit pack fulfilling and refund handling
|
11
|
+
class CreditPack
|
12
|
+
|
13
|
+
attr_reader :name,
|
14
|
+
:credits, :bonus_credits,
|
15
|
+
:price_cents, :price_currency,
|
16
|
+
:metadata
|
17
|
+
|
18
|
+
def initialize(name)
|
19
|
+
@name = name
|
20
|
+
@credits = 0
|
21
|
+
@bonus_credits = 0
|
22
|
+
@price_cents = 0
|
23
|
+
@price_currency = UsageCredits.configuration.default_currency.to_s.upcase
|
24
|
+
@metadata = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
# =========================================
|
28
|
+
# DSL Methods (used in initializer blocks)
|
29
|
+
# =========================================
|
30
|
+
|
31
|
+
# Set the base number of credits
|
32
|
+
def gives(amount)
|
33
|
+
@credits = amount.to_i
|
34
|
+
end
|
35
|
+
|
36
|
+
# Set bonus credits (e.g., for promotions)
|
37
|
+
def bonus(amount)
|
38
|
+
@bonus_credits = amount.to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
# Set the price in cents (e.g., 4900 for $49.00)
|
42
|
+
def costs(cents)
|
43
|
+
@price_cents = cents
|
44
|
+
end
|
45
|
+
alias_method :cost, :costs
|
46
|
+
|
47
|
+
# Set the currency (defaults to configuration)
|
48
|
+
def currency(currency)
|
49
|
+
currency = currency.to_s.downcase.to_sym
|
50
|
+
unless UsageCredits::Configuration::VALID_CURRENCIES.include?(currency)
|
51
|
+
raise ArgumentError, "Invalid currency. Must be one of: #{UsageCredits::Configuration::VALID_CURRENCIES.join(', ')}"
|
52
|
+
end
|
53
|
+
@price_currency = currency.to_s.upcase
|
54
|
+
end
|
55
|
+
|
56
|
+
# Add custom metadata
|
57
|
+
def meta(hash)
|
58
|
+
@metadata.merge!(hash.transform_keys(&:to_sym))
|
59
|
+
end
|
60
|
+
|
61
|
+
# =========================================
|
62
|
+
# Validation
|
63
|
+
# =========================================
|
64
|
+
|
65
|
+
def validate!
|
66
|
+
raise ArgumentError, "Name can't be blank" if name.blank?
|
67
|
+
raise ArgumentError, "Credits must be greater than 0" unless credits.to_i.positive?
|
68
|
+
raise ArgumentError, "Bonus credits must be greater than or equal to 0" if bonus_credits.to_i.negative?
|
69
|
+
raise ArgumentError, "Price must be greater than 0" unless price_cents.to_i.positive?
|
70
|
+
raise ArgumentError, "Currency can't be blank" if price_currency.blank?
|
71
|
+
# raise ArgumentError, "Price must be in whole cents ($49 = 4900)" if price_cents % 100 != 0 # Payment processors should handle this anyways
|
72
|
+
true
|
73
|
+
end
|
74
|
+
|
75
|
+
# =========================================
|
76
|
+
# Credit & Price Calculations
|
77
|
+
# =========================================
|
78
|
+
|
79
|
+
# Get total credits (including bonus)
|
80
|
+
def total_credits
|
81
|
+
credits + bonus_credits
|
82
|
+
end
|
83
|
+
|
84
|
+
# Get price in dollars
|
85
|
+
def price
|
86
|
+
price_cents / 100.0
|
87
|
+
end
|
88
|
+
|
89
|
+
# Get formatted price (e.g., "49.00 USD")
|
90
|
+
def formatted_price
|
91
|
+
format("%.2f %s", price, price_currency)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Get credits per dollar ratio (for comparison)
|
95
|
+
def credits_per_dollar
|
96
|
+
return 0 if price.zero?
|
97
|
+
total_credits / price
|
98
|
+
end
|
99
|
+
|
100
|
+
# =========================================
|
101
|
+
# Display Formatting
|
102
|
+
# =========================================
|
103
|
+
|
104
|
+
def display_credits
|
105
|
+
if bonus_credits.positive?
|
106
|
+
"#{credits} + #{bonus_credits} bonus credits"
|
107
|
+
else
|
108
|
+
"#{credits} credits"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
alias_method :display_description, :display_credits
|
112
|
+
|
113
|
+
def display_name
|
114
|
+
"#{name.to_s.titleize} pack"
|
115
|
+
end
|
116
|
+
|
117
|
+
# Generate human-friendly button text for purchase links
|
118
|
+
def button_text
|
119
|
+
"Get #{display_credits} for #{formatted_price}"
|
120
|
+
end
|
121
|
+
|
122
|
+
# =========================================
|
123
|
+
# Payment Integration
|
124
|
+
# =========================================
|
125
|
+
|
126
|
+
# Create a Stripe Checkout session for this pack
|
127
|
+
def create_checkout_session(user)
|
128
|
+
raise ArgumentError, "User must have a payment processor" unless user.respond_to?(:payment_processor) && user.payment_processor
|
129
|
+
|
130
|
+
user.payment_processor.checkout(
|
131
|
+
mode: "payment",
|
132
|
+
line_items: [{
|
133
|
+
price_data: {
|
134
|
+
currency: price_currency.downcase,
|
135
|
+
unit_amount: price_cents,
|
136
|
+
product_data: {
|
137
|
+
name: display_name,
|
138
|
+
description: display_description
|
139
|
+
}
|
140
|
+
},
|
141
|
+
quantity: 1
|
142
|
+
}],
|
143
|
+
payment_intent_data: { metadata: base_metadata },
|
144
|
+
metadata: base_metadata
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
def base_metadata
|
149
|
+
{
|
150
|
+
purchase_type: "credit_pack",
|
151
|
+
pack_name: name,
|
152
|
+
credits: credits,
|
153
|
+
bonus_credits: bonus_credits,
|
154
|
+
price_cents: price_cents,
|
155
|
+
price_currency: price_currency
|
156
|
+
}
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module UsageCredits
|
4
|
+
# A DSL to define subscription plans that give credits to users on a recurring basis.
|
5
|
+
#
|
6
|
+
# The actual credit fulfillment is handled by the PaySubscriptionExtension,
|
7
|
+
# which monitors subscription events (creation, renewal, etc) and adds
|
8
|
+
# credits to the user's wallet accordingly.
|
9
|
+
#
|
10
|
+
# @see PaySubscriptionExtension for the actual credit fulfillment logic
|
11
|
+
class CreditSubscriptionPlan
|
12
|
+
attr_reader :name,
|
13
|
+
:processor_plan_ids,
|
14
|
+
:fulfillment_period, :credits_per_period,
|
15
|
+
:signup_bonus_credits, :trial_credits,
|
16
|
+
:rollover_enabled,
|
17
|
+
:expire_credits_on_cancel, :credit_expiration_period,
|
18
|
+
:metadata
|
19
|
+
|
20
|
+
attr_writer :fulfillment_period
|
21
|
+
|
22
|
+
MIN_PERIOD = 1.day
|
23
|
+
|
24
|
+
def initialize(name)
|
25
|
+
@name = name
|
26
|
+
@processor_plan_ids = {} # Store processor-specific plan IDs
|
27
|
+
@fulfillment_period = nil
|
28
|
+
@credits_per_period = 0
|
29
|
+
@signup_bonus_credits = 0
|
30
|
+
@trial_credits = 0
|
31
|
+
@rollover_enabled = false
|
32
|
+
@expire_credits_on_cancel = false
|
33
|
+
@credit_expiration_period = nil
|
34
|
+
@metadata = {}
|
35
|
+
end
|
36
|
+
|
37
|
+
# =========================================
|
38
|
+
# DSL Methods (used in initializer blocks)
|
39
|
+
# =========================================
|
40
|
+
|
41
|
+
# Set base credits given each period
|
42
|
+
def gives(amount)
|
43
|
+
if amount.is_a?(UsageCredits::Cost::Fixed)
|
44
|
+
@credits_per_period = amount.amount
|
45
|
+
@fulfillment_period = UsageCredits::PeriodParser.normalize_period(amount.period || 1.month)
|
46
|
+
self
|
47
|
+
else
|
48
|
+
@credits_per_period = amount.to_i
|
49
|
+
CreditGiver.new(self)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# One-time signup bonus credits
|
54
|
+
def signup_bonus(amount)
|
55
|
+
@signup_bonus_credits = amount.to_i
|
56
|
+
end
|
57
|
+
|
58
|
+
# Credits given during trial period
|
59
|
+
def trial_includes(amount)
|
60
|
+
@trial_credits = amount.to_i
|
61
|
+
end
|
62
|
+
|
63
|
+
# Configure whether unused credits roll over between periods
|
64
|
+
def unused_credits(behavior)
|
65
|
+
@rollover_enabled = (behavior == :rollover)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Configure credit expiration after subscription cancellation
|
69
|
+
#
|
70
|
+
# When a subscription is cancelled, you can control what happens to remaining credits:
|
71
|
+
# 1. By default (if this is not called), users keep their credits forever
|
72
|
+
# 2. If called with a duration, credits expire after that grace period
|
73
|
+
# 3. If called with nil/0, credits expire immediately on cancellation
|
74
|
+
#
|
75
|
+
# @param duration [ActiveSupport::Duration, nil] Grace period before credits expire
|
76
|
+
# @return [void]
|
77
|
+
def expire_after(duration)
|
78
|
+
@expire_credits_on_cancel = true
|
79
|
+
@credit_expiration_period = duration
|
80
|
+
end
|
81
|
+
|
82
|
+
# Add custom metadata
|
83
|
+
def meta(hash)
|
84
|
+
@metadata.merge!(hash)
|
85
|
+
end
|
86
|
+
|
87
|
+
# =========================================
|
88
|
+
# Payment Processor Integration
|
89
|
+
# =========================================
|
90
|
+
|
91
|
+
# Set the processor-specific plan ID
|
92
|
+
def processor_plan(processor, id)
|
93
|
+
processor_plan_ids[processor.to_sym] = id
|
94
|
+
end
|
95
|
+
|
96
|
+
# Get the plan ID for a specific processor
|
97
|
+
def plan_id_for(processor)
|
98
|
+
processor_plan_ids[processor.to_sym]
|
99
|
+
end
|
100
|
+
|
101
|
+
# Shorthand for Stripe price ID
|
102
|
+
def stripe_price(id = nil)
|
103
|
+
if id.nil?
|
104
|
+
plan_id_for(:stripe) # getter
|
105
|
+
else
|
106
|
+
processor_plan(:stripe, id) # setter
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Create a checkout session for this subscription plan
|
111
|
+
def create_checkout_session(user, success_url:, cancel_url:, processor: :stripe)
|
112
|
+
raise ArgumentError, "User must respond to payment_processor" unless user.respond_to?(:payment_processor)
|
113
|
+
raise ArgumentError, "No fulfillment period configured for plan: #{name}" unless fulfillment_period
|
114
|
+
|
115
|
+
plan_id = plan_id_for(processor)
|
116
|
+
raise ArgumentError, "No #{processor.to_s.titleize} plan ID configured for plan: #{name}" unless plan_id
|
117
|
+
|
118
|
+
case processor
|
119
|
+
when :stripe
|
120
|
+
create_stripe_checkout_session(user, plan_id, success_url, cancel_url)
|
121
|
+
else
|
122
|
+
raise ArgumentError, "Unsupported payment processor: #{processor}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# =========================================
|
127
|
+
# Validation
|
128
|
+
# =========================================
|
129
|
+
|
130
|
+
def validate!
|
131
|
+
raise ArgumentError, "Name can't be blank" if name.blank?
|
132
|
+
raise ArgumentError, "Credits per period must be greater than 0" unless credits_per_period.to_i.positive?
|
133
|
+
raise ArgumentError, "Fulfillment period must be set" if fulfillment_period.nil?
|
134
|
+
raise ArgumentError, "Signup bonus credits must be greater than or equal to 0" if signup_bonus_credits.to_i.negative?
|
135
|
+
raise ArgumentError, "Trial credits must be greater than or equal to 0" if trial_credits.to_i.negative?
|
136
|
+
true
|
137
|
+
end
|
138
|
+
|
139
|
+
# =========================================
|
140
|
+
# Helper Methods
|
141
|
+
# =========================================
|
142
|
+
|
143
|
+
def fulfillment_period_display
|
144
|
+
fulfillment_period.is_a?(ActiveSupport::Duration) ? fulfillment_period.inspect : fulfillment_period
|
145
|
+
end
|
146
|
+
|
147
|
+
def parsed_fulfillment_period
|
148
|
+
UsageCredits::PeriodParser.parse_period(@fulfillment_period)
|
149
|
+
end
|
150
|
+
|
151
|
+
def create_stripe_checkout_session(user, plan_id, success_url, cancel_url)
|
152
|
+
user.payment_processor.checkout(
|
153
|
+
mode: "subscription",
|
154
|
+
line_items: [{
|
155
|
+
price: plan_id,
|
156
|
+
quantity: 1
|
157
|
+
}],
|
158
|
+
success_url: success_url,
|
159
|
+
cancel_url: cancel_url,
|
160
|
+
payment_intent_data: { metadata: base_metadata },
|
161
|
+
subscription_data: { metadata: base_metadata }
|
162
|
+
)
|
163
|
+
end
|
164
|
+
|
165
|
+
def base_metadata
|
166
|
+
{
|
167
|
+
purchase_type: "credit_subscription",
|
168
|
+
subscription_name: name,
|
169
|
+
fulfillment_period: fulfillment_period_display,
|
170
|
+
credits_per_period: credits_per_period,
|
171
|
+
signup_bonus_credits: signup_bonus_credits,
|
172
|
+
trial_credits: trial_credits,
|
173
|
+
rollover_enabled: rollover_enabled,
|
174
|
+
expire_credits_on_cancel: expire_credits_on_cancel,
|
175
|
+
credit_expiration_period: credit_expiration_period&.to_i,
|
176
|
+
metadata: metadata
|
177
|
+
}
|
178
|
+
end
|
179
|
+
|
180
|
+
# =========================================
|
181
|
+
# DSL Helper Classes
|
182
|
+
# =========================================
|
183
|
+
|
184
|
+
# CreditGiver is a helper class that enables the DSL within `subscription_plan` blocks to define credit amounts.
|
185
|
+
#
|
186
|
+
# This is what allows us to write:
|
187
|
+
# gives 10_000.credits.every(:month)
|
188
|
+
#
|
189
|
+
# Instead of:
|
190
|
+
# set_credits(10_000)
|
191
|
+
# set_period(:month)
|
192
|
+
class CreditGiver
|
193
|
+
def initialize(plan)
|
194
|
+
@plan = plan
|
195
|
+
end
|
196
|
+
|
197
|
+
def every(period)
|
198
|
+
@plan.fulfillment_period = UsageCredits::PeriodParser.normalize_period(period)
|
199
|
+
@plan
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
end
|
204
|
+
end
|