usage_credits 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +8 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +559 -0
  6. data/Rakefile +32 -0
  7. data/lib/generators/usage_credits/install_generator.rb +49 -0
  8. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +88 -0
  9. data/lib/generators/usage_credits/templates/initializer.rb +105 -0
  10. data/lib/usage_credits/configuration.rb +204 -0
  11. data/lib/usage_credits/core_ext/numeric.rb +59 -0
  12. data/lib/usage_credits/cost/base.rb +43 -0
  13. data/lib/usage_credits/cost/compound.rb +37 -0
  14. data/lib/usage_credits/cost/fixed.rb +34 -0
  15. data/lib/usage_credits/cost/variable.rb +42 -0
  16. data/lib/usage_credits/engine.rb +37 -0
  17. data/lib/usage_credits/helpers/credit_calculator.rb +34 -0
  18. data/lib/usage_credits/helpers/credits_helper.rb +45 -0
  19. data/lib/usage_credits/helpers/period_parser.rb +77 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +25 -0
  21. data/lib/usage_credits/models/allocation.rb +31 -0
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +94 -0
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +198 -0
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +251 -0
  25. data/lib/usage_credits/models/credit_pack.rb +159 -0
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +204 -0
  27. data/lib/usage_credits/models/fulfillment.rb +91 -0
  28. data/lib/usage_credits/models/operation.rb +153 -0
  29. data/lib/usage_credits/models/transaction.rb +174 -0
  30. data/lib/usage_credits/models/wallet.rb +310 -0
  31. data/lib/usage_credits/railtie.rb +17 -0
  32. data/lib/usage_credits/services/fulfillment_service.rb +129 -0
  33. data/lib/usage_credits/version.rb +5 -0
  34. data/lib/usage_credits.rb +170 -0
  35. data/sig/usagecredits.rbs +4 -0
  36. 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