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