usage_credits 0.4.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +246 -13
- data/lib/generators/usage_credits/install_generator.rb +9 -1
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
- data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
- data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
- data/lib/usage_credits/callbacks.rb +9 -54
- data/lib/usage_credits/configuration.rb +43 -13
- data/lib/usage_credits/core_ext/numeric.rb +12 -16
- data/lib/usage_credits/cost/base.rb +1 -6
- data/lib/usage_credits/cost/compound.rb +3 -2
- data/lib/usage_credits/cost/fixed.rb +1 -2
- data/lib/usage_credits/cost/variable.rb +23 -8
- data/lib/usage_credits/engine.rb +8 -12
- data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
- data/lib/usage_credits/helpers/credits_helper.rb +9 -11
- data/lib/usage_credits/helpers/period_parser.rb +31 -9
- data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
- data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
- data/lib/usage_credits/models/allocation.rb +15 -20
- data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
- data/lib/usage_credits/models/credit_pack.rb +34 -13
- data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
- data/lib/usage_credits/models/fulfillment.rb +23 -48
- data/lib/usage_credits/models/operation.rb +36 -28
- data/lib/usage_credits/models/transaction.rb +55 -152
- data/lib/usage_credits/models/transfer.rb +37 -0
- data/lib/usage_credits/models/wallet.rb +219 -314
- data/lib/usage_credits/railtie.rb +4 -13
- data/lib/usage_credits/services/fulfillment_service.rb +146 -22
- data/lib/usage_credits/subscription_terms.rb +131 -0
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +16 -3
- metadata +33 -20
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -48
- data/AGENTS.md +0 -5
- data/Appraisals +0 -43
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -32
- data/gemfiles/pay_10.0.gemfile +0 -41
- data/gemfiles/pay_11.0.gemfile +0 -41
- data/gemfiles/pay_8.3.gemfile +0 -41
- data/gemfiles/pay_9.0.gemfile +0 -41
- data/gemfiles/rails_7.2.gemfile +0 -41
- data/gemfiles/rails_8.1.gemfile +0 -41
|
@@ -31,13 +31,14 @@ module UsageCredits
|
|
|
31
31
|
included do
|
|
32
32
|
# For initial setup and fulfillment, we can't do after_create or on: :create because the subscription first may
|
|
33
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,
|
|
37
|
-
after_commit :update_fulfillment_on_cancellation,
|
|
38
|
-
after_commit :handle_plan_change_wrapper
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
after_commit :handle_initial_award_and_fulfillment_setup, on: [:create, :update]
|
|
35
|
+
|
|
36
|
+
after_commit :update_fulfillment_on_renewal, on: :update, if: :subscription_renewed?
|
|
37
|
+
after_commit :update_fulfillment_on_cancellation, on: :update, if: :subscription_canceled?
|
|
38
|
+
after_commit :handle_plan_change_wrapper, on: :update
|
|
39
|
+
after_commit :apply_deferred_plan_change_after_resume,
|
|
40
|
+
on: :update,
|
|
41
|
+
if: :usage_credit_resume_state_changed?
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
# Identify the usage_credits plan object
|
|
@@ -47,11 +48,41 @@ module UsageCredits
|
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def provides_credits?
|
|
50
|
-
|
|
51
|
+
subscription_terms.present?
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def fulfillment_should_stop_at
|
|
54
|
-
|
|
55
|
+
ends_at || current_period_end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Pay's processor-specific #active? implementation is the source of truth
|
|
59
|
+
# for grace periods and effective pauses. Pay::Subscription itself does not
|
|
60
|
+
# implement #paused?, however, so legacy/base-class rows need the equivalent
|
|
61
|
+
# lifecycle check without calling Pay's otherwise unsafe base #active?.
|
|
62
|
+
# In particular, Stripe keeps the raw status as "active" while a void pause
|
|
63
|
+
# is in effect.
|
|
64
|
+
def eligible_for_usage_credit_fulfillment?(include_trial: false)
|
|
65
|
+
processor_active = if status == "on_trial"
|
|
66
|
+
respond_to?(:on_trial?) && on_trial?
|
|
67
|
+
elsif respond_to?(:paused?)
|
|
68
|
+
active?
|
|
69
|
+
else
|
|
70
|
+
["trialing", "active"].include?(status) && !ended?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
return false unless processor_active
|
|
74
|
+
return false if paused_for_usage_credits?
|
|
75
|
+
|
|
76
|
+
include_trial || !trialing_for_credits?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reconcile the initial/trial award state on demand. The recurring service
|
|
80
|
+
# uses this at lifecycle boundaries for processors (notably Braintree,
|
|
81
|
+
# whose status can remain `active` throughout a trial) and to finish a
|
|
82
|
+
# deferred plan change before any recurring credits can be minted.
|
|
83
|
+
def sync_usage_credit_fulfillment!
|
|
84
|
+
handle_initial_award_and_fulfillment_setup
|
|
85
|
+
apply_deferred_plan_change_after_resume
|
|
55
86
|
end
|
|
56
87
|
|
|
57
88
|
private
|
|
@@ -64,16 +95,14 @@ module UsageCredits
|
|
|
64
95
|
end
|
|
65
96
|
|
|
66
97
|
def credits_already_fulfilled?
|
|
67
|
-
# TODO: There's a race condition where Pay actually updates the subscription two times on initial creation,
|
|
68
|
-
# leading to us triggering handle_initial_award_and_fulfillment_setup twice too.
|
|
69
|
-
# Since no Fulfillment record has been created yet, both callbacks will try to create the same Fulfillment object
|
|
70
|
-
# at about the same time, thus making this check useless (there's nothing written to the DB yet)
|
|
71
|
-
# For now, we handle it by adding a validation to the Fulfillment model so that there's no two Fulfillment objects
|
|
72
|
-
# with the same source_id -- so whichever of the two callbacks gets processed first wins, the other just fails.
|
|
73
|
-
# That's how we prevent double credit awarding for now, but this race condition should be handled more elegantly.
|
|
74
98
|
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
75
99
|
return false unless fulfillment
|
|
76
100
|
|
|
101
|
+
# Deferred terms own resume reconciliation. Treat the fulfillment as
|
|
102
|
+
# initialized until those terms are atomically installed so the generic
|
|
103
|
+
# reactivation path cannot mint an unintended extra first cycle.
|
|
104
|
+
return true if fulfillment.metadata.key?("deferred_plan_change")
|
|
105
|
+
|
|
77
106
|
# A stopped fulfillment (stops_at in the past) should NOT prevent reactivation
|
|
78
107
|
# This handles: credit → non-credit → credit transitions (after stop date)
|
|
79
108
|
return false if fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current
|
|
@@ -82,7 +111,27 @@ module UsageCredits
|
|
|
82
111
|
# should also allow reactivation - user changed their mind before the stop took effect
|
|
83
112
|
return false if fulfillment.metadata["stopped_reason"].present?
|
|
84
113
|
|
|
85
|
-
|
|
114
|
+
initial_award_completed?(fulfillment)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def initial_award_completed?(fulfillment)
|
|
118
|
+
award_state = fulfillment.metadata["initial_award_state"]
|
|
119
|
+
|
|
120
|
+
if trialing_for_credits?
|
|
121
|
+
# Any existing trial/active initial award makes a trial callback
|
|
122
|
+
# idempotent. Active subscriptions never move backwards into trial.
|
|
123
|
+
true
|
|
124
|
+
elsif status == "active"
|
|
125
|
+
return true if award_state == "active"
|
|
126
|
+
return false if award_state == "trial" || fulfillment.metadata["trial"]
|
|
127
|
+
|
|
128
|
+
# Pre-1.0 fulfillments do not carry initial_award_state. Conservatively
|
|
129
|
+
# treat a non-trial legacy row as already active to avoid over-crediting
|
|
130
|
+
# existing customers during upgrade.
|
|
131
|
+
true
|
|
132
|
+
else
|
|
133
|
+
false
|
|
134
|
+
end
|
|
86
135
|
end
|
|
87
136
|
|
|
88
137
|
# Returns an existing fulfillment that is stopped or scheduled to stop
|
|
@@ -102,7 +151,8 @@ module UsageCredits
|
|
|
102
151
|
end
|
|
103
152
|
|
|
104
153
|
def subscription_renewed?
|
|
105
|
-
(saved_change_to_ends_at? || saved_change_to_current_period_end?) &&
|
|
154
|
+
(saved_change_to_ends_at? || saved_change_to_current_period_end?) &&
|
|
155
|
+
eligible_for_usage_credit_fulfillment?
|
|
106
156
|
end
|
|
107
157
|
|
|
108
158
|
# This doesn't get called the exact moment the user cancels its subscription, but at the end of the period,
|
|
@@ -114,7 +164,8 @@ module UsageCredits
|
|
|
114
164
|
end
|
|
115
165
|
|
|
116
166
|
def plan_changed?
|
|
117
|
-
return false unless saved_change_to_processor_plan?
|
|
167
|
+
return false unless saved_change_to_processor_plan?
|
|
168
|
+
return false unless eligible_for_usage_credit_fulfillment? || paused_for_usage_credits?
|
|
118
169
|
|
|
119
170
|
# The old plan ID must be present (not nil) - otherwise this is initial subscription creation
|
|
120
171
|
# not a plan change. Initial subscription is handled by handle_initial_award_and_fulfillment_setup.
|
|
@@ -125,7 +176,8 @@ module UsageCredits
|
|
|
125
176
|
# If old plan wasn't a credit plan (not in config), then handle_initial_award_and_fulfillment_setup
|
|
126
177
|
# will handle the "fresh start" case - we don't want to double-award credits.
|
|
127
178
|
old_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(old_plan_id)
|
|
128
|
-
|
|
179
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
180
|
+
return false unless old_plan.present? || fulfillment.present?
|
|
129
181
|
|
|
130
182
|
# At this point, old plan provided credits. We handle:
|
|
131
183
|
# - Credit → Credit (upgrade/downgrade)
|
|
@@ -139,69 +191,119 @@ module UsageCredits
|
|
|
139
191
|
|
|
140
192
|
# Immediate awarding of first cycle + set up Fulfillment object for subsequent periods
|
|
141
193
|
def handle_initial_award_and_fulfillment_setup
|
|
142
|
-
|
|
194
|
+
plan = subscription_terms
|
|
195
|
+
return unless plan
|
|
143
196
|
return unless has_valid_wallet?
|
|
144
197
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
existing_reactivatable_fulfillment = reactivatable_fulfillment
|
|
150
|
-
is_reactivation = existing_reactivatable_fulfillment.present?
|
|
198
|
+
# Pay normalizes trials and pauses differently per processor. Trust its
|
|
199
|
+
# active predicate instead of the raw status, which remains "active" for
|
|
200
|
+
# an effective Stripe void pause.
|
|
201
|
+
return unless eligible_for_usage_credit_fulfillment?(include_trial: true)
|
|
151
202
|
|
|
152
203
|
# Skip if we already have an ACTIVE fulfillment record
|
|
153
204
|
return if credits_already_fulfilled?
|
|
154
205
|
|
|
155
|
-
plan = credit_subscription_plan
|
|
156
206
|
wallet = customer.owner.credit_wallet
|
|
157
207
|
|
|
158
208
|
# Calculate credit expiration using the shared helper
|
|
159
209
|
credits_expire_at = calculate_credit_expiration(plan, current_period_start)
|
|
160
210
|
|
|
161
|
-
Rails.logger.info "Fulfilling #{is_reactivation ? 'reactivation' : 'initial'} credits for subscription #{id}"
|
|
162
|
-
Rails.logger.info " Status: #{status}"
|
|
163
|
-
Rails.logger.info " Plan: #{plan}"
|
|
164
|
-
|
|
165
211
|
# Variables to track for callback dispatch after transaction commits
|
|
166
212
|
total_credits_awarded = 0
|
|
167
213
|
last_credit_transaction = nil
|
|
214
|
+
is_reactivation = false
|
|
168
215
|
|
|
169
216
|
# Transaction for atomic awarding + fulfillment creation/reactivation
|
|
170
217
|
# Callback is dispatched AFTER this block to ensure credits are persisted
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
#
|
|
175
|
-
|
|
218
|
+
self.class.transaction do
|
|
219
|
+
# Lock order for every subscription mutation is subscription →
|
|
220
|
+
# fulfillment → wallet. Serializing on the Pay row closes duplicate
|
|
221
|
+
# webhook races and prevents lock-order deadlocks with recurring jobs.
|
|
222
|
+
next unless lock_current_subscription_version
|
|
223
|
+
next unless eligible_for_usage_credit_fulfillment?(include_trial: true)
|
|
224
|
+
existing_fulfillment = UsageCredits::Fulfillment.lock.find_by(source: self)
|
|
225
|
+
wallet.lock!
|
|
226
|
+
next if existing_fulfillment&.metadata&.key?("deferred_plan_change")
|
|
227
|
+
next if existing_fulfillment && initial_award_completed?(existing_fulfillment) && !reactivatable_record?(existing_fulfillment)
|
|
228
|
+
|
|
229
|
+
is_reactivation = existing_fulfillment.present? && reactivatable_record?(existing_fulfillment)
|
|
230
|
+
is_trial_activation = existing_fulfillment.present? && status == "active" && !trialing_for_credits? && !is_reactivation
|
|
231
|
+
|
|
232
|
+
Rails.logger.info "Fulfilling #{is_reactivation ? "reactivation" : "initial"} credits for subscription #{id}"
|
|
233
|
+
Rails.logger.info " Status: #{status}"
|
|
234
|
+
Rails.logger.info " Plan: #{plan}"
|
|
235
|
+
|
|
236
|
+
# Create or reactivate the fulfillment before minting so every ledger
|
|
237
|
+
# row is linked at INSERT time. The enclosing transaction keeps the
|
|
238
|
+
# temporary zero amount invisible and rolls everything back together.
|
|
239
|
+
fulfilled_at = Time.current
|
|
240
|
+
next_fulfillment_at = next_subscription_fulfillment_at(plan)
|
|
241
|
+
award_state = trialing_for_credits? ? "trial" : "active"
|
|
242
|
+
|
|
243
|
+
fulfillment_record = if is_reactivation || is_trial_activation
|
|
244
|
+
existing_fulfillment.tap do |record|
|
|
245
|
+
record.update!(
|
|
246
|
+
credits_last_fulfillment: 0,
|
|
247
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
248
|
+
last_fulfilled_at: fulfilled_at,
|
|
249
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
250
|
+
stops_at: fulfillment_should_stop_at,
|
|
251
|
+
metadata: record.metadata
|
|
252
|
+
.except(
|
|
253
|
+
"trial", "stopped_reason", "stopped_at", "stopped_plan",
|
|
254
|
+
"pending_plan_change", "pending_plan_snapshot", "plan_change_at"
|
|
255
|
+
)
|
|
256
|
+
.merge(plan_snapshot_metadata(plan))
|
|
257
|
+
.merge(
|
|
258
|
+
"subscription_id" => id,
|
|
259
|
+
"initial_award_state" => award_state,
|
|
260
|
+
(is_reactivation ? "reactivated_at" : "activated_at") => fulfilled_at
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
else
|
|
265
|
+
UsageCredits::Fulfillment.create!(
|
|
266
|
+
wallet: wallet,
|
|
267
|
+
source: self,
|
|
268
|
+
fulfillment_type: "subscription",
|
|
269
|
+
credits_last_fulfillment: 0,
|
|
270
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
271
|
+
last_fulfilled_at: fulfilled_at,
|
|
272
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
273
|
+
stops_at: fulfillment_should_stop_at,
|
|
274
|
+
metadata: {
|
|
275
|
+
"subscription_id" => id,
|
|
276
|
+
"initial_award_state" => award_state,
|
|
277
|
+
"trial" => trialing_for_credits?
|
|
278
|
+
}.merge(plan_snapshot_metadata(plan))
|
|
279
|
+
)
|
|
280
|
+
end
|
|
176
281
|
|
|
177
|
-
|
|
282
|
+
# If this is a trial and not an active subscription, award trial credits.
|
|
283
|
+
if trialing_for_credits? && plan.trial_credits.positive?
|
|
178
284
|
last_credit_transaction = wallet.add_credits(plan.trial_credits,
|
|
179
285
|
category: "subscription_trial",
|
|
180
286
|
expires_at: trial_ends_at,
|
|
287
|
+
fulfillment: fulfillment_record,
|
|
181
288
|
metadata: {
|
|
182
289
|
subscription_id: id,
|
|
183
290
|
reason: is_reactivation ? "reactivation_trial_credits" : "initial_trial_credits",
|
|
184
291
|
plan: processor_plan,
|
|
185
|
-
fulfilled_at:
|
|
186
|
-
}
|
|
187
|
-
)
|
|
188
|
-
transaction_ids << last_credit_transaction.id
|
|
292
|
+
fulfilled_at: fulfilled_at
|
|
293
|
+
})
|
|
189
294
|
total_credits_awarded += plan.trial_credits
|
|
190
|
-
|
|
191
295
|
elsif status == "active"
|
|
192
|
-
|
|
193
296
|
# Awarding of signup bonus, if any (only on initial setup, not reactivation)
|
|
194
297
|
if plan.signup_bonus_credits.positive? && !is_reactivation
|
|
195
298
|
bonus_transaction = wallet.add_credits(plan.signup_bonus_credits,
|
|
196
299
|
category: "subscription_signup_bonus",
|
|
300
|
+
fulfillment: fulfillment_record,
|
|
197
301
|
metadata: {
|
|
198
302
|
subscription_id: id,
|
|
199
303
|
reason: "signup_bonus",
|
|
200
304
|
plan: processor_plan,
|
|
201
|
-
fulfilled_at:
|
|
202
|
-
}
|
|
203
|
-
)
|
|
204
|
-
transaction_ids << bonus_transaction.id
|
|
305
|
+
fulfilled_at: fulfilled_at
|
|
306
|
+
})
|
|
205
307
|
total_credits_awarded += plan.signup_bonus_credits
|
|
206
308
|
last_credit_transaction = bonus_transaction
|
|
207
309
|
end
|
|
@@ -210,110 +312,74 @@ module UsageCredits
|
|
|
210
312
|
if plan.credits_per_period.positive?
|
|
211
313
|
credits_transaction = wallet.add_credits(plan.credits_per_period,
|
|
212
314
|
category: "subscription_credits",
|
|
213
|
-
expires_at: credits_expire_at,
|
|
315
|
+
expires_at: credits_expire_at,
|
|
316
|
+
fulfillment: fulfillment_record,
|
|
214
317
|
metadata: {
|
|
215
318
|
subscription_id: id,
|
|
216
319
|
reason: is_reactivation ? "reactivation" : "first_cycle",
|
|
217
320
|
plan: processor_plan,
|
|
218
|
-
fulfilled_at:
|
|
219
|
-
}
|
|
220
|
-
)
|
|
221
|
-
transaction_ids << credits_transaction.id
|
|
321
|
+
fulfilled_at: fulfilled_at
|
|
322
|
+
})
|
|
222
323
|
total_credits_awarded += plan.credits_per_period
|
|
223
324
|
last_credit_transaction = credits_transaction
|
|
224
325
|
end
|
|
225
326
|
end
|
|
226
327
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
period_start = if trial_ends_at && status == "trialing"
|
|
230
|
-
trial_ends_at
|
|
231
|
-
else
|
|
232
|
-
current_period_start || Time.current
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Ensure next_fulfillment_at is in the future
|
|
236
|
-
next_fulfillment_at = period_start + plan.parsed_fulfillment_period
|
|
237
|
-
next_fulfillment_at = Time.current + plan.parsed_fulfillment_period if next_fulfillment_at <= Time.current
|
|
238
|
-
|
|
239
|
-
if is_reactivation
|
|
240
|
-
# Reactivate the existing stopped/scheduled-to-stop fulfillment
|
|
241
|
-
# Merge metadata to preserve any custom keys while updating core fields
|
|
242
|
-
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
243
|
-
existing_reactivatable_fulfillment.update!(
|
|
244
|
-
credits_last_fulfillment: total_credits_awarded,
|
|
245
|
-
fulfillment_period: plan.fulfillment_period_display,
|
|
246
|
-
last_fulfilled_at: Time.current,
|
|
247
|
-
next_fulfillment_at: next_fulfillment_at,
|
|
248
|
-
stops_at: fulfillment_should_stop_at,
|
|
249
|
-
metadata: existing_reactivatable_fulfillment.metadata
|
|
250
|
-
.except("stopped_reason", "stopped_at", "pending_plan_change", "plan_change_at")
|
|
251
|
-
.merge(
|
|
252
|
-
"subscription_id" => id,
|
|
253
|
-
"plan" => processor_plan,
|
|
254
|
-
"reactivated_at" => Time.current
|
|
255
|
-
)
|
|
256
|
-
)
|
|
257
|
-
|
|
258
|
-
Rails.logger.info "Reactivated fulfillment #{existing_reactivatable_fulfillment.id} for subscription #{id}"
|
|
259
|
-
else
|
|
260
|
-
# Create new fulfillment
|
|
261
|
-
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
262
|
-
UsageCredits::Fulfillment.create!(
|
|
263
|
-
wallet: wallet,
|
|
264
|
-
source: self,
|
|
265
|
-
fulfillment_type: "subscription",
|
|
266
|
-
credits_last_fulfillment: total_credits_awarded,
|
|
267
|
-
fulfillment_period: plan.fulfillment_period_display,
|
|
268
|
-
last_fulfilled_at: Time.current,
|
|
269
|
-
next_fulfillment_at: next_fulfillment_at,
|
|
270
|
-
stops_at: fulfillment_should_stop_at, # Pre-emptively set when the fulfillment will stop, in case we miss a future event (like sub cancellation)
|
|
271
|
-
metadata: {
|
|
272
|
-
"subscription_id" => id,
|
|
273
|
-
"plan" => processor_plan,
|
|
274
|
-
}
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
Rails.logger.info "Initial fulfillment for subscription #{id} finished"
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Link created transactions to the fulfillment object for traceability
|
|
281
|
-
fulfillment_record = UsageCredits::Fulfillment.find_by(source: self)
|
|
282
|
-
UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment_record&.id) if transaction_ids.any?
|
|
328
|
+
fulfillment_record.update!(credits_last_fulfillment: total_credits_awarded)
|
|
329
|
+
Rails.logger.info "Fulfillment #{fulfillment_record.id} updated for subscription #{id}"
|
|
283
330
|
end
|
|
284
331
|
|
|
285
332
|
# Dispatch callback AFTER transaction commits - ensures credits are persisted
|
|
286
333
|
if total_credits_awarded > 0
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
334
|
+
ActiveRecord.after_all_transactions_commit do
|
|
335
|
+
UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
|
|
336
|
+
wallet: wallet,
|
|
337
|
+
amount: total_credits_awarded,
|
|
338
|
+
transaction: last_credit_transaction,
|
|
339
|
+
metadata: {
|
|
340
|
+
subscription_plan_name: plan.name,
|
|
341
|
+
subscription: plan.callback_plan,
|
|
342
|
+
pay_subscription: self,
|
|
343
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
344
|
+
is_reactivation: is_reactivation,
|
|
345
|
+
status: status
|
|
346
|
+
})
|
|
347
|
+
end
|
|
300
348
|
end
|
|
301
|
-
|
|
302
349
|
rescue => e
|
|
303
350
|
Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
|
|
304
351
|
raise
|
|
305
352
|
end
|
|
306
353
|
|
|
354
|
+
def reactivatable_record?(fulfillment)
|
|
355
|
+
(fulfillment.stops_at.present? && fulfillment.stops_at <= Time.current) ||
|
|
356
|
+
fulfillment.metadata["stopped_reason"].present?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def next_subscription_fulfillment_at(plan)
|
|
360
|
+
# Trial credits hand off at the actual processor trial boundary. Once
|
|
361
|
+
# active, credit cadence is intentionally independent of billing cadence
|
|
362
|
+
# (for example a monthly charge can grant credits daily).
|
|
363
|
+
trial_boundary = trial_ends_at || current_period_end
|
|
364
|
+
return trial_boundary if trialing_for_credits? && trial_boundary.present? && trial_boundary > Time.current
|
|
365
|
+
|
|
366
|
+
period_start = [current_period_start || Time.current, Time.current].max
|
|
367
|
+
candidate = period_start + plan.parsed_fulfillment_period
|
|
368
|
+
(candidate > Time.current) ? candidate : Time.current + plan.parsed_fulfillment_period
|
|
369
|
+
end
|
|
370
|
+
|
|
307
371
|
# Handle subscription renewal (we received a new payment for another billing period)
|
|
308
372
|
# Each time the subscription renews and ends_at moves forward,
|
|
309
373
|
# we keep awarding credits because Fulfillment#stops_at also moves forward
|
|
310
374
|
def update_fulfillment_on_renewal
|
|
311
|
-
return unless
|
|
375
|
+
return unless has_valid_wallet?
|
|
312
376
|
|
|
313
377
|
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
314
378
|
return unless fulfillment
|
|
315
379
|
|
|
316
|
-
|
|
380
|
+
self.class.transaction do
|
|
381
|
+
next unless lock_current_subscription_version
|
|
382
|
+
fulfillment.lock!
|
|
317
383
|
# Check if there's a pending plan change to apply
|
|
318
384
|
if fulfillment.metadata["pending_plan_change"].present?
|
|
319
385
|
apply_pending_plan_change(fulfillment)
|
|
@@ -324,32 +390,53 @@ module UsageCredits
|
|
|
324
390
|
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at updated to #{fulfillment.stops_at}"
|
|
325
391
|
rescue => e
|
|
326
392
|
Rails.logger.error "Failed to extend fulfillment period for subscription #{id}: #{e.message}"
|
|
327
|
-
raise
|
|
393
|
+
raise
|
|
328
394
|
end
|
|
329
395
|
end
|
|
330
396
|
|
|
331
|
-
|
|
332
397
|
# If the subscription is canceled, let's set the Fulfillment's stops_at so that the job won't keep awarding
|
|
333
398
|
def update_fulfillment_on_cancellation
|
|
334
|
-
|
|
335
|
-
return unless plan && has_valid_wallet?
|
|
399
|
+
return unless has_valid_wallet?
|
|
336
400
|
|
|
337
401
|
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
338
402
|
return unless fulfillment
|
|
339
403
|
|
|
340
|
-
|
|
404
|
+
self.class.transaction do
|
|
405
|
+
next unless lock_current_subscription_version
|
|
406
|
+
fulfillment.lock!
|
|
407
|
+
wallet = fulfillment.wallet
|
|
408
|
+
wallet.lock!
|
|
409
|
+
|
|
410
|
+
active_plan_id = fulfillment.metadata["plan"]
|
|
411
|
+
configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(active_plan_id)
|
|
412
|
+
terms = terms_from_fulfillment(
|
|
413
|
+
fulfillment,
|
|
414
|
+
expected_plan_id: active_plan_id,
|
|
415
|
+
configured_plan: configured_plan
|
|
416
|
+
) || UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: active_plan_id)
|
|
417
|
+
|
|
341
418
|
# Subscription cancelled, so stop awarding credits in the future
|
|
342
|
-
|
|
419
|
+
fulfillment_attributes = {stops_at: fulfillment_should_stop_at}
|
|
420
|
+
if terms&.expire_credits_on_cancel
|
|
421
|
+
expires_at = cancellation_credit_expiration_at(terms)
|
|
422
|
+
wallet.expire_fulfillment_credits!(fulfillment: fulfillment, expires_at: expires_at)
|
|
423
|
+
fulfillment_attributes[:metadata] = fulfillment.metadata.merge(
|
|
424
|
+
"cancellation_credit_expiration_at" => expires_at,
|
|
425
|
+
"cancellation_credit_expiration_applied_at" => Time.current
|
|
426
|
+
)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
fulfillment.update!(fulfillment_attributes)
|
|
343
430
|
Rails.logger.info "Fulfillment #{fulfillment.id} stops_at set to #{fulfillment.stops_at} due to cancellation"
|
|
344
431
|
rescue => e
|
|
345
432
|
Rails.logger.error "Failed to stop credit fulfillment for subscription #{id}: #{e.message}"
|
|
346
|
-
raise
|
|
433
|
+
raise
|
|
347
434
|
end
|
|
435
|
+
end
|
|
348
436
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
437
|
+
def cancellation_credit_expiration_at(terms)
|
|
438
|
+
effective_cancellation_at = fulfillment_should_stop_at || Time.current
|
|
439
|
+
effective_cancellation_at + terms.credit_expiration_period_seconds.seconds
|
|
353
440
|
end
|
|
354
441
|
|
|
355
442
|
# Wrapper to check condition and call handle_plan_change
|
|
@@ -389,28 +476,41 @@ module UsageCredits
|
|
|
389
476
|
Rails.logger.info " Looking up current plan: #{current_plan_id}"
|
|
390
477
|
Rails.logger.info " Looking up new plan: #{new_plan_id}"
|
|
391
478
|
|
|
392
|
-
current_plan =
|
|
479
|
+
current_plan = terms_from_fulfillment(fulfillment, expected_plan_id: current_plan_id) ||
|
|
480
|
+
terms_from_config(current_plan_id)
|
|
393
481
|
new_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(new_plan_id)
|
|
394
482
|
|
|
395
483
|
Rails.logger.info " Current plan found: #{current_plan&.name} (#{current_plan&.credits_per_period} credits)"
|
|
396
484
|
Rails.logger.info " New plan found: #{new_plan&.name} (#{new_plan&.credits_per_period} credits)"
|
|
397
485
|
|
|
486
|
+
# A processor can change plans while service is paused. Persist the new
|
|
487
|
+
# immutable terms, but never mint an upgrade until service resumes. A
|
|
488
|
+
# later resume callback atomically installs the deferred terms; the next
|
|
489
|
+
# normal fulfillment then uses them.
|
|
490
|
+
if paused_for_usage_credits?
|
|
491
|
+
defer_plan_change_until_resume(fulfillment, new_plan)
|
|
492
|
+
return
|
|
493
|
+
end
|
|
494
|
+
|
|
398
495
|
# Handle downgrade to a non-credit plan: schedule fulfillment stop for end of period
|
|
399
|
-
if new_plan.nil? &&
|
|
496
|
+
if new_plan.nil? && current_plan_id.present?
|
|
400
497
|
handle_downgrade_to_non_credit_plan(fulfillment)
|
|
401
498
|
return
|
|
402
499
|
end
|
|
403
500
|
|
|
404
501
|
return unless new_plan # Neither current nor new plan provides credits - nothing to do
|
|
405
502
|
|
|
406
|
-
|
|
503
|
+
self.class.transaction do
|
|
504
|
+
next unless lock_current_subscription_version
|
|
505
|
+
next unless eligible_for_usage_credit_fulfillment?
|
|
506
|
+
fulfillment.lock!
|
|
407
507
|
# FIRST: Check if returning to current plan (canceling a pending change)
|
|
408
508
|
# This must come first! Returning to current plan = no credits, just clear pending
|
|
409
509
|
# This matches Stripe's billing: no new charge means no new credits
|
|
410
510
|
if current_plan_id == new_plan_id
|
|
411
511
|
Rails.logger.info " Action: Returning to current plan (clearing pending change)"
|
|
412
512
|
clear_pending_plan_change(fulfillment)
|
|
413
|
-
|
|
513
|
+
next
|
|
414
514
|
end
|
|
415
515
|
|
|
416
516
|
# Now compare credits to determine upgrade vs downgrade
|
|
@@ -430,12 +530,12 @@ module UsageCredits
|
|
|
430
530
|
else
|
|
431
531
|
# Same credits amount, different plan - update metadata immediately
|
|
432
532
|
Rails.logger.info " Action: Same credits, different plan - updating metadata only"
|
|
433
|
-
update_fulfillment_plan_metadata(fulfillment,
|
|
533
|
+
update_fulfillment_plan_metadata(fulfillment, new_plan)
|
|
434
534
|
end
|
|
435
535
|
rescue => e
|
|
436
536
|
Rails.logger.error "Failed to handle plan change for subscription #{id}: #{e.message}"
|
|
437
537
|
Rails.logger.error e.backtrace.join("\n")
|
|
438
|
-
raise
|
|
538
|
+
raise
|
|
439
539
|
end
|
|
440
540
|
|
|
441
541
|
Rails.logger.info " Plan change completed successfully"
|
|
@@ -451,9 +551,9 @@ module UsageCredits
|
|
|
451
551
|
Rails.logger.info " [UPGRADE] New plan period: #{new_plan.fulfillment_period_display}"
|
|
452
552
|
|
|
453
553
|
# Calculate expiration using shared helper (uses current_period_end for upgrades)
|
|
454
|
-
credits_expire_at = calculate_credit_expiration(new_plan,
|
|
554
|
+
credits_expire_at = calculate_credit_expiration(new_plan, Time.current)
|
|
455
555
|
|
|
456
|
-
Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at ||
|
|
556
|
+
Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || "never (rollover enabled)"}"
|
|
457
557
|
|
|
458
558
|
# Calculate next fulfillment time based on the NEW plan's period
|
|
459
559
|
# This ensures the fulfillment schedule matches the new plan's cadence
|
|
@@ -463,13 +563,14 @@ module UsageCredits
|
|
|
463
563
|
# The callback should only fire after ALL operations succeed
|
|
464
564
|
upgrade_transaction = nil
|
|
465
565
|
|
|
466
|
-
|
|
566
|
+
fulfillment.class.transaction do
|
|
467
567
|
# Grant full new plan credits immediately
|
|
468
568
|
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
469
569
|
upgrade_transaction = wallet.add_credits(
|
|
470
570
|
new_plan.credits_per_period,
|
|
471
571
|
category: "subscription_upgrade",
|
|
472
572
|
expires_at: credits_expire_at,
|
|
573
|
+
fulfillment: fulfillment,
|
|
473
574
|
metadata: {
|
|
474
575
|
"subscription_id" => id,
|
|
475
576
|
"plan" => processor_plan,
|
|
@@ -489,27 +590,30 @@ module UsageCredits
|
|
|
489
590
|
# to ensure future fulfillments happen on the correct schedule
|
|
490
591
|
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
491
592
|
fulfillment.update!(
|
|
593
|
+
credits_last_fulfillment: new_plan.credits_per_period,
|
|
594
|
+
last_fulfilled_at: Time.current,
|
|
492
595
|
fulfillment_period: new_plan.fulfillment_period_display,
|
|
493
596
|
next_fulfillment_at: next_fulfillment_at,
|
|
494
597
|
metadata: fulfillment.metadata
|
|
495
|
-
.except("pending_plan_change", "plan_change_at")
|
|
496
|
-
.merge(
|
|
598
|
+
.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
|
|
599
|
+
.merge(plan_snapshot_metadata(new_plan))
|
|
497
600
|
)
|
|
498
601
|
end
|
|
499
602
|
|
|
500
603
|
# Dispatch callback AFTER transaction commits - ensures credits are persisted
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
604
|
+
ActiveRecord.after_all_transactions_commit do
|
|
605
|
+
UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
|
|
606
|
+
wallet: wallet,
|
|
607
|
+
amount: new_plan.credits_per_period,
|
|
608
|
+
transaction: upgrade_transaction,
|
|
609
|
+
metadata: {
|
|
610
|
+
subscription_plan_name: new_plan.name,
|
|
611
|
+
subscription: new_plan,
|
|
612
|
+
pay_subscription: self,
|
|
613
|
+
fulfillment_period: new_plan.fulfillment_period_display,
|
|
614
|
+
reason: "plan_upgrade"
|
|
615
|
+
})
|
|
616
|
+
end
|
|
513
617
|
|
|
514
618
|
Rails.logger.info " [UPGRADE] Credits awarded successfully"
|
|
515
619
|
Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}"
|
|
@@ -529,6 +633,7 @@ module UsageCredits
|
|
|
529
633
|
fulfillment.update!(
|
|
530
634
|
metadata: fulfillment.metadata.merge(
|
|
531
635
|
"pending_plan_change" => processor_plan,
|
|
636
|
+
"pending_plan_snapshot" => plan_snapshot_metadata(new_plan),
|
|
532
637
|
"plan_change_at" => schedule_time
|
|
533
638
|
)
|
|
534
639
|
)
|
|
@@ -543,13 +648,16 @@ module UsageCredits
|
|
|
543
648
|
# Ensure schedule_time is never in the past
|
|
544
649
|
schedule_time = [current_period_end || Time.current, Time.current].max
|
|
545
650
|
|
|
546
|
-
|
|
651
|
+
self.class.transaction do
|
|
652
|
+
next unless lock_current_subscription_version
|
|
653
|
+
fulfillment.lock!
|
|
547
654
|
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
548
655
|
fulfillment.update!(
|
|
549
656
|
stops_at: schedule_time,
|
|
550
657
|
metadata: fulfillment.metadata.merge(
|
|
551
658
|
"stopped_reason" => "downgrade_to_non_credit_plan",
|
|
552
|
-
"stopped_at" => schedule_time
|
|
659
|
+
"stopped_at" => schedule_time,
|
|
660
|
+
"stopped_plan" => processor_plan
|
|
553
661
|
)
|
|
554
662
|
)
|
|
555
663
|
|
|
@@ -557,15 +665,21 @@ module UsageCredits
|
|
|
557
665
|
rescue => e
|
|
558
666
|
Rails.logger.error "Failed to handle downgrade to non-credit plan for subscription #{id}: #{e.message}"
|
|
559
667
|
Rails.logger.error e.backtrace.join("\n")
|
|
560
|
-
raise
|
|
668
|
+
raise
|
|
561
669
|
end
|
|
562
670
|
end
|
|
563
671
|
|
|
564
|
-
def update_fulfillment_plan_metadata(fulfillment,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
672
|
+
def update_fulfillment_plan_metadata(fulfillment, new_plan)
|
|
673
|
+
attributes = {
|
|
674
|
+
metadata: fulfillment.metadata.merge(plan_snapshot_metadata(new_plan))
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if fulfillment.fulfillment_period != new_plan.fulfillment_period_display
|
|
678
|
+
attributes[:fulfillment_period] = new_plan.fulfillment_period_display
|
|
679
|
+
attributes[:next_fulfillment_at] = Time.current + new_plan.parsed_fulfillment_period
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
fulfillment.update!(attributes)
|
|
569
683
|
end
|
|
570
684
|
|
|
571
685
|
# Clear any pending plan change metadata
|
|
@@ -574,32 +688,108 @@ module UsageCredits
|
|
|
574
688
|
return unless fulfillment.metadata["pending_plan_change"].present?
|
|
575
689
|
|
|
576
690
|
fulfillment.update!(
|
|
577
|
-
metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
|
|
691
|
+
metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
|
|
578
692
|
)
|
|
579
693
|
|
|
580
694
|
Rails.logger.info "Subscription #{id} pending plan change cleared (returned to current plan)"
|
|
581
695
|
end
|
|
582
696
|
|
|
697
|
+
def defer_plan_change_until_resume(fulfillment, new_plan)
|
|
698
|
+
self.class.transaction do
|
|
699
|
+
next unless lock_current_subscription_version
|
|
700
|
+
next unless paused_for_usage_credits?
|
|
701
|
+
fulfillment.lock!
|
|
702
|
+
|
|
703
|
+
if new_plan.nil?
|
|
704
|
+
fulfillment.update!(
|
|
705
|
+
stops_at: Time.current,
|
|
706
|
+
metadata: fulfillment.metadata
|
|
707
|
+
.except("deferred_plan_change", "deferred_plan_snapshot")
|
|
708
|
+
.merge(
|
|
709
|
+
"stopped_reason" => "paused_change_to_non_credit_plan",
|
|
710
|
+
"stopped_at" => Time.current,
|
|
711
|
+
"stopped_plan" => processor_plan
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
elsif fulfillment.metadata["plan"] == processor_plan && !reactivatable_record?(fulfillment)
|
|
715
|
+
fulfillment.update!(
|
|
716
|
+
metadata: fulfillment.metadata.except("deferred_plan_change", "deferred_plan_snapshot")
|
|
717
|
+
)
|
|
718
|
+
else
|
|
719
|
+
fulfillment.update!(
|
|
720
|
+
metadata: fulfillment.metadata.merge(
|
|
721
|
+
"deferred_plan_change" => processor_plan,
|
|
722
|
+
"deferred_plan_snapshot" => plan_snapshot_metadata(new_plan)
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
def apply_deferred_plan_change_after_resume
|
|
730
|
+
return unless eligible_for_usage_credit_fulfillment?
|
|
731
|
+
|
|
732
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
733
|
+
return unless fulfillment&.metadata&.key?("deferred_plan_change")
|
|
734
|
+
|
|
735
|
+
self.class.transaction do
|
|
736
|
+
next unless lock_current_subscription_version
|
|
737
|
+
next unless eligible_for_usage_credit_fulfillment?
|
|
738
|
+
fulfillment.lock!
|
|
739
|
+
|
|
740
|
+
plan_id = fulfillment.metadata["deferred_plan_change"]
|
|
741
|
+
snapshot = fulfillment.metadata["deferred_plan_snapshot"]
|
|
742
|
+
unless plan_id.to_s == processor_plan.to_s && snapshot&.fetch("plan", nil).to_s == plan_id.to_s
|
|
743
|
+
raise UsageCredits::InvalidOperation,
|
|
744
|
+
"Deferred plan terms for Pay::Subscription #{id} do not match its current processor plan"
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
fulfillment.update!(
|
|
748
|
+
stops_at: fulfillment_should_stop_at,
|
|
749
|
+
fulfillment_period: snapshot.fetch("fulfillment_period"),
|
|
750
|
+
metadata: fulfillment.metadata
|
|
751
|
+
.except(
|
|
752
|
+
"deferred_plan_change",
|
|
753
|
+
"deferred_plan_snapshot",
|
|
754
|
+
"pending_plan_change",
|
|
755
|
+
"pending_plan_snapshot",
|
|
756
|
+
"plan_change_at",
|
|
757
|
+
"stopped_reason",
|
|
758
|
+
"stopped_at",
|
|
759
|
+
"stopped_plan"
|
|
760
|
+
)
|
|
761
|
+
.merge(snapshot)
|
|
762
|
+
)
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
583
766
|
def apply_pending_plan_change(fulfillment)
|
|
584
767
|
pending_plan = fulfillment.metadata["pending_plan_change"]
|
|
768
|
+
pending_snapshot = fulfillment.metadata["pending_plan_snapshot"]
|
|
769
|
+
pending_snapshot = nil unless pending_snapshot&.fetch("plan", nil) == pending_plan
|
|
770
|
+
configured_plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(pending_plan)
|
|
585
771
|
|
|
586
772
|
# Validate that the pending plan still exists in configuration
|
|
587
773
|
# This handles the edge case where an admin removes a plan after a user scheduled a downgrade
|
|
588
|
-
unless
|
|
774
|
+
unless configured_plan || pending_snapshot.present?
|
|
589
775
|
Rails.logger.error "Cannot apply pending plan change for subscription #{id}: plan '#{pending_plan}' not found in configuration"
|
|
590
776
|
# Clear the invalid pending change to prevent repeated failures
|
|
591
777
|
fulfillment.update!(
|
|
592
|
-
metadata: fulfillment.metadata.except("pending_plan_change", "plan_change_at")
|
|
778
|
+
metadata: fulfillment.metadata.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
|
|
593
779
|
)
|
|
594
780
|
return
|
|
595
781
|
end
|
|
596
782
|
|
|
597
|
-
|
|
598
|
-
|
|
783
|
+
snapshot = pending_snapshot.presence || plan_snapshot_metadata(configured_plan, plan_id: pending_plan)
|
|
784
|
+
period = snapshot.fetch("fulfillment_period")
|
|
785
|
+
|
|
786
|
+
# Update all cadence and quantity fields, not only the display metadata.
|
|
599
787
|
fulfillment.update!(
|
|
788
|
+
fulfillment_period: period,
|
|
789
|
+
next_fulfillment_at: Time.current + UsageCredits::PeriodParser.parse_persisted_period(period),
|
|
600
790
|
metadata: fulfillment.metadata
|
|
601
|
-
.except("pending_plan_change", "plan_change_at")
|
|
602
|
-
.merge(
|
|
791
|
+
.except("pending_plan_change", "pending_plan_snapshot", "plan_change_at")
|
|
792
|
+
.merge(snapshot)
|
|
603
793
|
)
|
|
604
794
|
|
|
605
795
|
Rails.logger.info "Applied pending plan change for subscription #{id}: now on #{pending_plan}"
|
|
@@ -631,5 +821,131 @@ module UsageCredits
|
|
|
631
821
|
effective_base + fulfillment_period + effective_grace
|
|
632
822
|
end
|
|
633
823
|
|
|
824
|
+
def lock_current_subscription_version
|
|
825
|
+
current = self.class.lock.find_by(id: id)
|
|
826
|
+
return false unless current
|
|
827
|
+
|
|
828
|
+
%i[
|
|
829
|
+
updated_at status processor_plan current_period_start current_period_end
|
|
830
|
+
trial_ends_at ends_at pause_starts_at pause_behavior pause_resumes_at metadata
|
|
831
|
+
].all? do |attribute|
|
|
832
|
+
!has_attribute?(attribute) || database_equivalent_attribute?(
|
|
833
|
+
attribute,
|
|
834
|
+
current.public_send(attribute),
|
|
835
|
+
public_send(attribute)
|
|
836
|
+
)
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
|
|
840
|
+
# Processor timestamps can carry nanoseconds while Rails' timestamp
|
|
841
|
+
# columns persist microseconds. Comparing the raw callback object against
|
|
842
|
+
# the locked row would therefore reject the exact state that was just
|
|
843
|
+
# saved on adapters/platforms that leave the extra nanoseconds in memory.
|
|
844
|
+
# Preserve strict comparisons for every non-temporal value, and compare
|
|
845
|
+
# temporal values at the column's effective database precision.
|
|
846
|
+
def database_equivalent_attribute?(attribute, persisted_value, callback_value)
|
|
847
|
+
return true if persisted_value == callback_value
|
|
848
|
+
|
|
849
|
+
type = self.class.type_for_attribute(attribute.to_s)
|
|
850
|
+
return false unless [:datetime, :time].include?(type.type)
|
|
851
|
+
return false if persisted_value.nil? || callback_value.nil?
|
|
852
|
+
|
|
853
|
+
column_precision = self.class.columns_hash.fetch(attribute.to_s).precision
|
|
854
|
+
precision = (column_precision || 6).clamp(0, 9)
|
|
855
|
+
scale = 10**precision
|
|
856
|
+
|
|
857
|
+
(persisted_value.to_r * scale).floor == (callback_value.to_r * scale).floor
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
# Deferred terms only need callback-time reconciliation when a processor
|
|
861
|
+
# lifecycle field may have moved a subscription out of a pause. The
|
|
862
|
+
# recurring service still calls the reconciliation method directly as a
|
|
863
|
+
# recovery path, so unrelated subscription updates can skip its lookup.
|
|
864
|
+
def usage_credit_resume_state_changed?
|
|
865
|
+
%w[status pause_behavior pause_starts_at pause_resumes_at].any? do |attribute|
|
|
866
|
+
has_attribute?(attribute) && saved_change_to_attribute?(attribute)
|
|
867
|
+
end
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def paused_for_usage_credits?
|
|
871
|
+
return true if status == "paused"
|
|
872
|
+
|
|
873
|
+
pause_started = respond_to?(:pause_starts_at) && pause_starts_at.present? && pause_starts_at <= Time.current
|
|
874
|
+
return true if pause_started
|
|
875
|
+
|
|
876
|
+
respond_to?(:paused?) && paused? &&
|
|
877
|
+
(!respond_to?(:on_grace_period?) || !on_grace_period?)
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def trialing_for_credits?
|
|
881
|
+
return true if ["on_trial", "trialing"].include?(status)
|
|
882
|
+
|
|
883
|
+
transitioned_to_active = saved_change_to_status? && ["on_trial", "trialing"].include?(status_before_last_save)
|
|
884
|
+
return false if transitioned_to_active
|
|
885
|
+
return on_trial? if respond_to?(:on_trial?)
|
|
886
|
+
|
|
887
|
+
false
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def plan_snapshot_metadata(plan, plan_id: processor_plan)
|
|
891
|
+
cancellation_expiration_seconds =
|
|
892
|
+
if plan.respond_to?(:credit_expiration_period_seconds)
|
|
893
|
+
plan.credit_expiration_period_seconds
|
|
894
|
+
else
|
|
895
|
+
plan.credit_expiration_period&.to_i || 0
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
{
|
|
899
|
+
"plan" => plan_id,
|
|
900
|
+
"plan_name" => plan.name,
|
|
901
|
+
"credits_per_period" => plan.credits_per_period,
|
|
902
|
+
"signup_bonus_credits" => plan.signup_bonus_credits,
|
|
903
|
+
"trial_credits" => plan.trial_credits,
|
|
904
|
+
"fulfillment_period" => plan.fulfillment_period_display,
|
|
905
|
+
"rollover_enabled" => plan.rollover_enabled,
|
|
906
|
+
"expire_credits_on_cancel" => plan.expire_credits_on_cancel,
|
|
907
|
+
"credit_expiration_period" => cancellation_expiration_seconds
|
|
908
|
+
}
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def subscription_terms
|
|
912
|
+
configured_plan = credit_subscription_plan
|
|
913
|
+
fulfillment = UsageCredits::Fulfillment.find_by(source: self)
|
|
914
|
+
|
|
915
|
+
terms = terms_from_fulfillment(fulfillment, expected_plan_id: processor_plan, configured_plan: configured_plan)
|
|
916
|
+
return terms if terms
|
|
917
|
+
|
|
918
|
+
data = (metadata || {}).with_indifferent_access
|
|
919
|
+
metadata_plan = data[:processor_plan].presence
|
|
920
|
+
if data[:purchase_type] == "credit_subscription" && (metadata_plan.nil? || metadata_plan.to_s == processor_plan.to_s)
|
|
921
|
+
terms = terms_from_metadata(data, configured_plan: configured_plan)
|
|
922
|
+
return terms if terms
|
|
923
|
+
end
|
|
924
|
+
|
|
925
|
+
UsageCredits::SubscriptionTerms.from_plan(configured_plan, processor_plan_id: processor_plan)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def terms_from_fulfillment(fulfillment, expected_plan_id:, configured_plan: nil)
|
|
929
|
+
return unless fulfillment&.fulfillment_type == "subscription"
|
|
930
|
+
return unless fulfillment.metadata["plan"].to_s == expected_plan_id.to_s
|
|
931
|
+
|
|
932
|
+
terms_from_metadata(fulfillment.metadata, configured_plan: configured_plan, processor_plan_id: expected_plan_id)
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def terms_from_config(plan_id)
|
|
936
|
+
plan = UsageCredits.configuration.find_subscription_plan_by_processor_id(plan_id)
|
|
937
|
+
UsageCredits::SubscriptionTerms.from_plan(plan, processor_plan_id: plan_id)
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def terms_from_metadata(data, configured_plan:, processor_plan_id: processor_plan)
|
|
941
|
+
UsageCredits::SubscriptionTerms.from_metadata(
|
|
942
|
+
data,
|
|
943
|
+
processor_plan_id: processor_plan_id,
|
|
944
|
+
configured_plan: configured_plan
|
|
945
|
+
)
|
|
946
|
+
rescue ArgumentError => e
|
|
947
|
+
Rails.logger.error "Invalid subscription terms for Pay::Subscription #{id}: #{e.message}"
|
|
948
|
+
nil
|
|
949
|
+
end
|
|
634
950
|
end
|
|
635
951
|
end
|