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.
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