app_manager 2.5.0 → 2.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9661cef74e8d94fb367e3a6886459bfde70c41d37d5c520892cbb4c7c39f86d5
4
- data.tar.gz: 3b3dce409ba57c93e93373eded966f1b576e29918dd25bf3f74117e14debf156
3
+ metadata.gz: b07aa041091f2f5c6acc9fc85b9ffbf6c73d7d97f0694981fb3ee261fd4d167c
4
+ data.tar.gz: 3ef460faa396f6dbb9df6cb66010045975cd76b28a29e97d00b4542bb846a0c2
5
5
  SHA512:
6
- metadata.gz: 5c4169d529a20d888ea6c308a380dd9a9d91e8f654145338f5ca76ad42d3ac26ec2e4ce486dab845f2ee854129b282fa6fcdce471ca3e8e2e30ebfc6e07c9096
7
- data.tar.gz: a2cd325a7d10096a812644a47a0fdeed0bd51117ed13e35ee0cdd7b5950aa83553db2ed10a939a8ec388a4a5a33172ee8b43ec64bf946c9dd9c43f0547911ccd
6
+ metadata.gz: 22f3bb93b50b5064d8db803023882e48539e9c212703b922379378e3cff63d017020b96c7cf3d2bdf3402768e4011f1ffbbd7ba823d513c3d81c9881a6f91a69
7
+ data.tar.gz: 423fbd2cea4724aaeeacfc86fc6f90d15df211fc8a74a23c8e6ac2c93ff3744d27c502873d55b3beb03d3783db3492d74b26c63f2d0d940209668715bccbc8d1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- app_manager (2.5.0)
4
+ app_manager (2.7.0)
5
5
  activerecord-import (~> 1.4)
6
6
  httparty
7
7
  kaminari (>= 0.16.3)
@@ -92,27 +92,27 @@ GEM
92
92
  bigdecimal (4.0.1)
93
93
  builder (3.3.0)
94
94
  coderay (1.1.3)
95
- concurrent-ruby (1.3.6)
95
+ concurrent-ruby (1.3.8)
96
96
  connection_pool (3.0.2)
97
97
  crack (1.0.1)
98
98
  bigdecimal
99
99
  rexml
100
- crass (1.0.6)
100
+ crass (1.0.7)
101
101
  csv (3.3.5)
102
102
  date (3.5.1)
103
103
  diff-lcs (1.6.2)
104
104
  dotenv (2.1.2)
105
105
  drb (2.2.3)
106
- erb (6.0.4)
106
+ erb (6.0.6)
107
107
  erubi (1.13.1)
108
- globalid (1.3.0)
108
+ globalid (1.4.0)
109
109
  activesupport (>= 6.1)
110
110
  hashdiff (1.2.1)
111
111
  httparty (0.24.2)
112
112
  csv
113
113
  mini_mime (>= 1.0.0)
114
114
  multi_xml (>= 0.5.2)
115
- i18n (1.14.8)
115
+ i18n (1.15.2)
116
116
  concurrent-ruby (~> 1.0)
117
117
  io-console (0.8.2)
118
118
  irb (1.18.0)
@@ -120,7 +120,7 @@ GEM
120
120
  prism (>= 1.3.0)
121
121
  rdoc (>= 4.0.0)
122
122
  reline (>= 0.4.2)
123
- json (2.19.7)
123
+ json (2.21.1)
124
124
  kaminari (1.2.2)
125
125
  activesupport (>= 4.1.0)
126
126
  kaminari-actionview (= 1.2.2)
@@ -134,10 +134,10 @@ GEM
134
134
  kaminari-core (= 1.2.2)
135
135
  kaminari-core (1.2.2)
136
136
  logger (1.7.0)
137
- loofah (2.25.1)
137
+ loofah (2.25.2)
138
138
  crass (~> 1.0.2)
139
139
  nokogiri (>= 1.12.0)
140
- mail (2.9.0)
140
+ mail (2.9.1)
141
141
  logger
142
142
  mini_mime (>= 0.1.1)
143
143
  net-imap
@@ -152,7 +152,7 @@ GEM
152
152
  prism (~> 1.5)
153
153
  multi_xml (0.9.1)
154
154
  bigdecimal (>= 3.1, < 5)
155
- net-imap (0.6.4)
155
+ net-imap (0.6.4.1)
156
156
  date
157
157
  net-protocol
158
158
  net-pop (0.1.2)
@@ -162,12 +162,12 @@ GEM
162
162
  net-smtp (0.5.1)
163
163
  net-protocol
164
164
  nio4r (2.7.5)
165
- nokogiri (1.19.3)
165
+ nokogiri (1.19.4)
166
166
  mini_portile2 (~> 2.8.2)
167
167
  racc (~> 1.4)
168
- nokogiri (1.19.3-x86_64-linux-gnu)
168
+ nokogiri (1.19.4-x86_64-linux-gnu)
169
169
  racc (~> 1.4)
170
- pp (0.6.3)
170
+ pp (0.6.4)
171
171
  prettyprint
172
172
  prettyprint (0.2.0)
173
173
  prism (1.9.0)
@@ -175,9 +175,6 @@ GEM
175
175
  coderay (~> 1.1)
176
176
  method_source (~> 1.0)
177
177
  reline (>= 0.6.0)
178
- psych (5.3.1)
179
- date
180
- stringio
181
178
  public_suffix (7.0.2)
182
179
  racc (1.8.1)
183
180
  rack (3.2.6)
@@ -206,8 +203,8 @@ GEM
206
203
  activesupport (>= 5.0.0)
207
204
  minitest
208
205
  nokogiri (>= 1.6)
209
- rails-html-sanitizer (1.7.0)
210
- loofah (~> 2.25)
206
+ rails-html-sanitizer (1.7.1)
207
+ loofah (~> 2.25, >= 2.25.2)
211
208
  nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
212
209
  railties (8.1.3)
213
210
  actionpack (= 8.1.3)
@@ -219,9 +216,14 @@ GEM
219
216
  tsort (>= 0.2)
220
217
  zeitwerk (~> 2.6)
221
218
  rake (13.3.1)
222
- rdoc (7.2.0)
219
+ rbs (4.0.3)
220
+ logger
221
+ prism (>= 1.6.0)
222
+ tsort
223
+ rdoc (8.0.0)
223
224
  erb
224
- psych (>= 4.0.0)
225
+ prism (>= 1.6.0)
226
+ rbs (>= 4.0.0)
225
227
  tsort
226
228
  reline (0.6.3)
227
229
  io-console (~> 0.5)
@@ -242,7 +244,6 @@ GEM
242
244
  rspec-support (~> 3.13.0)
243
245
  rspec-support (3.13.7)
244
246
  securerandom (0.4.1)
245
- stringio (3.2.0)
246
247
  thor (1.5.0)
247
248
  timeout (0.6.1)
248
249
  tsort (0.2.0)
@@ -254,7 +255,7 @@ GEM
254
255
  addressable (>= 2.8.0)
255
256
  crack (>= 0.3.2)
256
257
  hashdiff (>= 0.4.0, < 2.0.0)
257
- websocket-driver (0.8.0)
258
+ websocket-driver (0.8.2)
258
259
  base64
259
260
  websocket-extensions (>= 0.1.0)
260
261
  websocket-extensions (0.1.5)
@@ -34,7 +34,7 @@ module AppManager
34
34
  Rollbar.error("APP MANAGER Process Plan Failed #{e.inspect}-#{e.backtrace}")
35
35
  end
36
36
  @trial_activated_field = AppManager.configuration.field_names['trial_activated_at']
37
- update_info = {@plan_field => params[:plan_id], @trial_activated_field => nil,grandfathered_field => 0}
37
+ update_info = {@plan_field => params[:plan_id], grandfathered_field => 0}
38
38
  if !config_trial_days.nil? && !plan_data.nil?
39
39
  trial_days = plan_data['trial_days'] || 0
40
40
  update_info[config_trial_days] = trial_days
@@ -58,7 +58,8 @@ module AppManager
58
58
  raise Error, "Invalid charge"
59
59
  end
60
60
  end
61
- request_data = {'shop' => @shop.shopify_domain, 'timestamp' => Time.now.to_i, 'plan' => params[:plan_id]}
61
+ charge_timestamp = Time.now.to_i
62
+ request_data = {'shop' => @shop.shopify_domain, 'timestamp' => charge_timestamp, 'plan' => params[:plan_id]}
62
63
  request_data.merge!('host' => params['host']) if params['host'].present?
63
64
  request_data.merge!('old_plan' => params['old_plan']) if params['old_plan'].present?
64
65
 
@@ -66,6 +67,14 @@ module AppManager
66
67
  gq_obj = AppManager::GraphqlHelper.new(@shop.shopify_domain, @shop.shopify_token)
67
68
  discount_local_storage = params[:discount_code].present? && !params[:discount_code].nil? ? params[:discount_code] : nil
68
69
  data = gq_obj.recurring_charge_api_call(plan_data, return_url, @shop,discount_local_storage)
70
+ # Stash the discount snapshot so the callback can persist it on the charge row.
71
+ if gq_obj.discount_snapshot.present?
72
+ begin
73
+ Rails.cache.write(AppManager.charge_snapshot_cache_key(@shop.shopify_domain, charge_timestamp), gq_obj.discount_snapshot, expires_in: 1.hour)
74
+ rescue Exception => e
75
+ Rollbar.error("APP MANAGER Failed to cache discount snapshot #{e.inspect}")
76
+ end
77
+ end
69
78
  if data.present? && !data["errors"].present? && (data["data"].present? && data["data"]["appSubscriptionCreate"].present? && (!data["data"]["appSubscriptionCreate"]["userErrors"].any? && data["data"]["appSubscriptionCreate"]["confirmationUrl"]))
70
79
  redirect_charge = data["data"]["appSubscriptionCreate"]["confirmationUrl"]
71
80
  render json: {'redirect_url' => redirect_charge}
@@ -111,6 +120,14 @@ module AppManager
111
120
 
112
121
  ['api_client_id', 'return_url', 'decorated_return_url','id','id','currency'].each { |k| charge.delete k }
113
122
 
123
+ # Merge the discount snapshot captured at charge creation onto the charge row.
124
+ snapshot_key = AppManager.charge_snapshot_cache_key(params[:shop], params[:timestamp])
125
+ discount_snapshot = Rails.cache.read(snapshot_key) rescue nil
126
+ if discount_snapshot.is_a?(Hash)
127
+ discount_snapshot.each { |field, value| charge[field] = value unless value.nil? || value == '' }
128
+ Rails.cache.delete(snapshot_key) rescue nil
129
+ end
130
+
114
131
  charge_ob = AppManager::Client.new(nil, json_req = true)
115
132
  response = charge_ob.store_charge(charge.to_json)
116
133
 
@@ -209,11 +226,10 @@ module AppManager
209
226
  if params[:shop].present? && params[:plan_id].present?
210
227
  @shop = shop_data
211
228
  grandfathered_field = @field_names['grandfathered']
212
- trial_activated_field = @field_names['trial_activated_at']
213
229
  if !@shop.nil?
214
230
  plan_obj = AppManager::Client.new
215
231
  plan_data = plan_obj.get_plan(params[:plan_id], params[:shop])
216
- update_info = {@plan_field => params[:plan_id], trial_activated_field => nil,grandfathered_field => 0}
232
+ update_info = {@plan_field => params[:plan_id], grandfathered_field => 0}
217
233
  if !config_trial_days.nil? && !plan_data.nil?
218
234
  trial_days = plan_data['trial_days'] || 0
219
235
  update_info[config_trial_days] = trial_days
@@ -242,9 +258,8 @@ module AppManager
242
258
  if params[:shop].present?
243
259
  @shop = shop_data
244
260
  grandfathered_field = @field_names['grandfathered']
245
- trial_activated_field = @field_names['trial_activated_at']
246
261
  if !@shop.nil?
247
- update_info = {@plan_field => nil, trial_activated_field => nil,grandfathered_field => 0}
262
+ update_info = {@plan_field => nil, grandfathered_field => 0}
248
263
  if !config_trial_days.nil?
249
264
  update_info[config_trial_days] = 0
250
265
  end
@@ -98,6 +98,30 @@ module AppManager
98
98
  app_faqs_obj = AppManager::Client.new
99
99
  app_faqs = app_faqs_obj.get_app_faqs || []
100
100
 
101
+ # Embed the active charge's effective pricing onto the matching plan(s), so it
102
+ # travels with the plan payload the frontend already consumes.
103
+ active_charge_pricing = nil
104
+ if active_charge.is_a?(Hash) && active_charge['active_charge'].is_a?(Hash) && active_charge['active_charge']['effective_price']
105
+ ac = active_charge['active_charge']
106
+ active_charge_pricing = {
107
+ 'plan_id' => ac['plan_id'],
108
+ 'interval' => ac['interval'],
109
+ 'effective_price' => ac['effective_price'],
110
+ 'strike_price' => ac['strike_price'],
111
+ 'is_discount_active' => ac['is_discount_active'] || false,
112
+ 'discount_ends_on' => ac['discount_ends_on'],
113
+ 'remaining_intervals' => ac['remaining_intervals'],
114
+ 'discount_value' => ac['discount_value'],
115
+ 'discount_type' => ac['discount_type'],
116
+ 'discount_duration_intervals' => ac['discount_duration_intervals']
117
+ }
118
+ end
119
+
120
+ if active_charge_pricing.present?
121
+ plans = plans.map { |p| with_active_charge_pricing(p, active_charge_pricing) } if plans.is_a?(Array)
122
+ plan = with_active_charge_pricing(plan, active_charge_pricing)
123
+ end
124
+
101
125
  response = {
102
126
  'plans' => plans,
103
127
  'promotional_discount' => promotional_discount,
@@ -108,13 +132,24 @@ module AppManager
108
132
  'default_plan_id' => default_plan_id,
109
133
  'most_popular_plan_ids' => most_popular_plan_ids,
110
134
  'choose_later' => choose_later,
111
- 'has_active_charge' => (((active_charge && active_charge.any? && !active_charge['active_charge'].nil?) or !trial_activated_at) ? true : false),
135
+ 'has_active_charge' => (((active_charge && active_charge.any? && !active_charge['active_charge'].nil?) || (plan.present? && plan['price'].to_f == 0) || !trial_activated_at) ? true : false),
112
136
  'global_plan_charge' => plan.present? && plan['is_global'] && active_charge.present? && active_charge.any? && !active_charge['active_charge'].nil? && !active_charge['bundle_charge'].nil?,
113
137
  'app_faqs' => app_faqs
114
138
  }
115
139
  render json: response
116
140
  end
117
141
 
142
+ # Attaches active_charge_pricing to a plan only when it is the plan the active
143
+ # charge belongs to (same id and, when known, same interval).
144
+ def with_active_charge_pricing(plan, pricing)
145
+ return plan unless plan.is_a?(Hash) && plan.present?
146
+ matches_plan = plan['id'].to_s == pricing['plan_id'].to_s
147
+ plan_interval = plan['interval'].is_a?(Hash) ? plan['interval']['value'] : plan['interval']
148
+ matches_interval = pricing['interval'].blank? || plan_interval == pricing['interval']
149
+ plan = plan.merge('active_charge_pricing' => pricing) if matches_plan && matches_interval
150
+ plan
151
+ end
152
+
118
153
  def users
119
154
  if model
120
155
 
@@ -170,7 +205,11 @@ module AppManager
170
205
  @trial_activated_field = AppManager.configuration.field_names['trial_activated_at']
171
206
  plan_obj = AppManager::Client.new
172
207
  plan_data = plan_obj.get_plan(params[:plan_id], params[:shop_domain]) rescue nil
173
- update_info = {@plan_field => params[:plan_id], @trial_activated_field => DateTime.now}
208
+ # Only stamp the trial start the first time; re-assigning a no-charge plan must
209
+ # not restart the trial clock (that would hand back trial days already consumed).
210
+ existing_trial_activated_at = @shop[@trial_activated_field] rescue nil
211
+ update_info = {@plan_field => params[:plan_id]}
212
+ update_info[@trial_activated_field] = DateTime.now if existing_trial_activated_at.blank?
174
213
  if !config_trial_days.nil? && !plan_data.nil?
175
214
  trial_days = plan_data['trial_days'] || 0
176
215
  update_info[config_trial_days] = trial_days
@@ -159,13 +159,26 @@ module AppManager
159
159
  charges.each do |charge|
160
160
  charge_test = charge['test'] ? true : false
161
161
  if !charge["id"].nil?
162
- charge_data << AppManager::Charge.new(c_id: charge["id"],charge_id: charge["charge_id"], test: charge_test, status: charge["status"], name: charge["name"], type: charge["type"], price: charge["price"], interval: charge["interval"], trial_days: charge["trial_days"], billing_on: charge["billing_on"], activated_on: charge["activated_on"], trial_ends_on: charge["trial_ends_on"], cancelled_on: charge["cancelled_on"], expires_on: charge["expires_on"], plan_id: charge["plan_id"], description: charge["description"], shop_domain: charge["shop_domain"], created_at: charge["created_at"], updated_at: charge["updated_at"], app_id: charge["app_id"], sync: true)
162
+ attrs = { c_id: charge["id"], charge_id: charge["charge_id"], test: charge_test, status: charge["status"], name: charge["name"], type: charge["type"], price: charge["price"], interval: charge["interval"], trial_days: charge["trial_days"], billing_on: charge["billing_on"], activated_on: charge["activated_on"], trial_ends_on: charge["trial_ends_on"], cancelled_on: charge["cancelled_on"], expires_on: charge["expires_on"], plan_id: charge["plan_id"], description: charge["description"], shop_domain: charge["shop_domain"], created_at: charge["created_at"], updated_at: charge["updated_at"], app_id: charge["app_id"], sync: true }
163
+ add_charge_discount_snapshot(attrs, charge)
164
+ charge_data << AppManager::Charge.new(attrs)
163
165
  end
164
166
  end
165
167
  AppManager::Charge.bulk_import charge_data
166
168
  end
167
169
  end
168
170
 
171
+ # Copies the discount snapshot fields (value/type/duration/source/promo id) from a
172
+ # charge payload onto the failsafe attrs, but only for columns that actually exist
173
+ # so older (un-migrated) installs keep working.
174
+ def add_charge_discount_snapshot(attrs, charge)
175
+ columns = AppManager::Charge.column_names
176
+ %w[discount_value discount_type discount_duration_intervals discount_source promotional_discount_id].each do |field|
177
+ attrs[field.to_sym] = charge[field] if columns.include?(field) && !charge[field].nil?
178
+ end
179
+ attrs
180
+ end
181
+
169
182
 
170
183
  def save_api_apps(apps)
171
184
  begin
@@ -204,8 +217,11 @@ module AppManager
204
217
  end
205
218
  if discount_plans.any?
206
219
  discount_plans_data = []
220
+ has_used_column = AppManager::DiscountPlan.column_names.include?('used')
207
221
  discount_plans.each do |discount_plan|
208
- discount_plans_data << AppManager::DiscountPlan.new(discount_plan_id: discount_plan['id'],discount: discount_plan['discount'], shop_domain: discount_plan['shop_domain'], cycle_count: discount_plan['cycle_count'], plan_id: discount_plan['plan_id'], created_by: discount_plan['created_by'], created_at: discount_plan['created_at'], updated_at: discount_plan['updated_at'], app_id: discount_plan['app_id'], discount_type: discount_plan['discount_type'])
222
+ attrs = { discount_plan_id: discount_plan['id'], discount: discount_plan['discount'], shop_domain: discount_plan['shop_domain'], cycle_count: discount_plan['cycle_count'], plan_id: discount_plan['plan_id'], created_by: discount_plan['created_by'], created_at: discount_plan['created_at'], updated_at: discount_plan['updated_at'], app_id: discount_plan['app_id'], discount_type: discount_plan['discount_type'] }
223
+ attrs[:used] = discount_plan['used'] ? true : false if has_used_column
224
+ discount_plans_data << AppManager::DiscountPlan.new(attrs)
209
225
  # discount_plans_data << AppManager::DiscountPlan.new(id: discount_plan['id'], discount: discount_plan['discount'], shop_domain: discount_plan['shop_domain'], cycle_count: discount_plan['cycle_count'], plan_id: discount_plan['plan_id'], created_by: discount_plan['created_by'], created_at: discount_plan['created_at'], updated_at: discount_plan['updated_at'], app_id: discount_plan['app_id'], discount_type: discount_plan['discount_type'])
210
226
  end
211
227
  AppManager::DiscountPlan.bulk_import discount_plans_data
@@ -238,8 +254,11 @@ module AppManager
238
254
  end
239
255
  if plan_users.any?
240
256
  extend_plan_users = []
257
+ has_used_column = AppManager::PlanUser.column_names.include?('used')
241
258
  plan_users.each do |plan_user|
242
- extend_plan_users << AppManager::PlanUser.new(plan_user_id: plan_user['id'],shop_domain: plan_user['shop_domain'], plan_id: plan_user['plan_id'], app_id: plan_user['app_id'],created_by: plan_user['created_by'], created_at: plan_user['created_at'], updated_at: plan_user['updated_at'])
259
+ attrs = { plan_user_id: plan_user['id'], shop_domain: plan_user['shop_domain'], plan_id: plan_user['plan_id'], app_id: plan_user['app_id'], created_by: plan_user['created_by'], created_at: plan_user['created_at'], updated_at: plan_user['updated_at'] }
260
+ attrs[:used] = plan_user['used'] ? true : false if has_used_column
261
+ extend_plan_users << AppManager::PlanUser.new(attrs)
243
262
  # extend_plan_users << AppManager::PlanUser.new(id: plan_user['id'], shop_domain: plan_user['shop_domain'], plan_id: plan_user['plan_id'], created_by: plan_user['created_by'], created_at: plan_user['created_at'], updated_at: plan_user['updated_at'])
244
263
  end
245
264
  AppManager::PlanUser.bulk_import extend_plan_users
@@ -396,6 +415,7 @@ module AppManager
396
415
 
397
416
  custom_plan_ids = []
398
417
  plan_users = AppManager::PlanUser.where(shop_domain: params['shop_domain'])
418
+ plan_users = plan_users.where(used: false) if AppManager::PlanUser.column_names.include?('used')
399
419
  custom_plan_ids = plan_users.pluck(:plan_id) if plan_users.present?
400
420
 
401
421
  custom_plan_base_ids = []
@@ -474,6 +494,7 @@ module AppManager
474
494
  custom_discounts_data = []
475
495
  if params["shop_domain"].present? && plan_data
476
496
  custom_discounts = AppManager::DiscountPlan.where(shop_domain: params['shop_domain']).order(created_at: :desc)
497
+ custom_discounts = custom_discounts.where(used: false) if AppManager::DiscountPlan.column_names.include?('used')
477
498
  if custom_discounts.present?
478
499
  custom_discounts.each do |custom_discount|
479
500
  new_custom_discount = {}
@@ -496,6 +517,7 @@ module AppManager
496
517
  plan['discount'] = cd['discount']
497
518
  plan['discount_type'] = cd['discount_type']
498
519
  plan['cycle_count'] = cd['cycle_count']
520
+ plan['discount_is_custom'] = true
499
521
  end
500
522
  end
501
523
 
@@ -556,10 +578,12 @@ module AppManager
556
578
  plan_data['old_plan_id'] = nil # Temporary for migration puspose only
557
579
  if params["shop_domain"].present? && plan_data
558
580
  discount_plans = AppManager::DiscountPlan.where(plan_id: params["plan_id"], shop_domain: params["shop_domain"])
581
+ discount_plans = discount_plans.where(used: false) if AppManager::DiscountPlan.column_names.include?('used')
559
582
  discount_plans.each do |cd|
560
583
  plan_data['discount'] = cd['discount'] if cd rescue plan_data['discount']
561
584
  plan_data['discount_type'] = cd['discount_type'] if cd rescue plan_data['discount_type']
562
585
  plan_data['cycle_count'] = cd['cycle_count'] if cd rescue plan_data['cycle_count']
586
+ plan_data['discount_is_custom'] = true if cd
563
587
  end
564
588
  end
565
589
 
@@ -572,54 +596,82 @@ module AppManager
572
596
  end
573
597
 
574
598
  def get_local_remaining_days(params, options)
575
- @remaining_days = 0
576
- @shop_domain = params['shop_domain']
577
- if params && params['trial_activated_at'].present? && !params['trial_activated_at'].nil? && params['shop_domain'].present? && params['plan_id'].present? && !params['plan_id'].nil?
578
- @trial_activated_at = params['trial_activated_at']
579
- @plan_id = params['plan_id']
580
- plan_data = AppManager::Plan.where(plan_id: @plan_id)
581
- if plan_data.any?
582
- trial_days = plan_data.first['trial_days']
583
- trial_start_date = Date.parse(@trial_activated_at)
584
- trial_end_date = trial_start_date + trial_days.days
585
- if trial_end_date > DateTime.now
586
- @remaining_days = (trial_end_date - DateTime.now).to_i
587
- end
588
- # return @remaining_days.inspect
589
- trial_extension_data = AppManager::ExtendTrial.where(shop_domain: @shop_domain, plan_id: @plan_id).order(extend_trial_start_at: :desc)
590
- if trial_extension_data.any?
591
- trial_extension_data = trial_extension_data.first
592
- extend_trial_date = trial_extension_data['created_at'].to_datetime + trial_extension_data['days'].to_i.days
593
- remaining_extended_days = DateTime.now < extend_trial_date ? (extend_trial_date - DateTime.now).to_i : 0
594
- @remaining_days = @remaining_days + remaining_extended_days
595
- end
599
+ shop_domain = params['shop_domain']
600
+ plan_id = params['plan_id']
601
+ trial_activated_at = params['trial_activated_at']
602
+
603
+ return nil if plan_id.blank?
604
+
605
+ plan_trial_days = (AppManager::Plan.where(plan_id: plan_id).limit(1).pluck(:trial_days).first || 0).to_i
606
+
607
+ # Extension stack - sum every record for this plan.
608
+ extension_days = AppManager::ExtendTrial.where(shop_domain: shop_domain, plan_id: plan_id).sum(:days).to_i
609
+
610
+ consumed = prepare_consumed_trial_days(shop_domain, trial_activated_at)
611
+
612
+ [0, (plan_trial_days + extension_days) - consumed].max
613
+ end
614
+
615
+ # Offline mirror of the portal's consumed-trial-days calculation. Total trial days
616
+ # already consumed, excluding time spent on a free plan (paused via
617
+ # charge.cancelled_on). Derived from existing failsafe tables only.
618
+ def prepare_consumed_trial_days(shop_domain, trial_activated_at = nil)
619
+ windows = []
620
+ earliest_charge_start = nil
621
+
622
+ charges = AppManager::Charge.where(shop_domain: shop_domain)
623
+ .where('trial_days > 0')
624
+ .where.not(trial_ends_on: nil)
625
+
626
+ charges.each do |charge|
627
+ trial_end = to_datetime_safe(charge['trial_ends_on'])
628
+ next if trial_end.nil?
629
+ start = trial_end - charge['trial_days'].to_i.days
630
+
631
+ earliest_charge_start = start if earliest_charge_start.nil? || start < earliest_charge_start
632
+
633
+ finish = DateTime.now
634
+ if charge['cancelled_on'].present?
635
+ cancelled_on = to_datetime_safe(charge['cancelled_on'])
636
+ finish = cancelled_on if cancelled_on && cancelled_on < finish
596
637
  end
597
- return @remaining_days
638
+ finish = trial_end if trial_end < finish
639
+ windows << [start, finish] if finish > start
598
640
  end
599
- @charges = AppManager::Charge.where(shop_domain: @shop_domain).order(created_at: :desc)
600
- if @charges.any?
601
- charge = @charges.first
602
641
 
603
- if charge['trial_days']
604
- if charge['trial_ends_on'] && DateTime.now < charge['trial_ends_on']
605
- @remaining_days = (charge['trial_ends_on'].to_datetime - DateTime.now.to_datetime).to_i
642
+ if trial_activated_at.present?
643
+ choose_later_plan = AppManager::Plan.where(choose_later_plan: true).order(id: :desc).first
644
+ if choose_later_plan && choose_later_plan['trial_days'].to_i > 0
645
+ cl_extension = AppManager::ExtendTrial.where(shop_domain: shop_domain, plan_id: choose_later_plan['plan_id']).sum(:days).to_i
646
+ length = choose_later_plan['trial_days'].to_i + cl_extension
647
+ start = to_datetime_safe(trial_activated_at)
648
+ unless start.nil?
649
+ natural_end = start + length.days
650
+ finish = DateTime.now < natural_end ? DateTime.now : natural_end
651
+ # No-charge trial ends when the first charge starts.
652
+ finish = earliest_charge_start if earliest_charge_start && earliest_charge_start < finish
653
+ windows << [start, finish] if finish > start
606
654
  end
655
+ end
656
+ end
607
657
 
608
- # ADD EXTRA DAY
609
- if charge['created_at'] && ((charge['created_at'].to_datetime - DateTime.now.to_datetime).to_i == 0)
610
- @remaining_days = @remaining_days + 1
611
- end
612
- # TODO: Uncomment this code when we implement Shopify trial extension apis
613
- # trial_extension_data = AppManager::ExtendTrial.where(shop_domain: @shop_domain, plan_id: @plan_id).order(extend_trial_start_at: :desc)
614
- # if trial_extension_data.any?
615
- # trial_extension_data = trial_extension_data.first
616
- # extend_trial_date = trial_extension_data['created_at'] + trial_extension_data['days'].to_i.days
617
- # remaining_extended_days = DateTime.now < extend_trial_date ? (extend_trial_date - DateTime.now).to_i : 0
618
- # @remaining_days = @remaining_days + remaining_extended_days
619
- # end
658
+ return 0 if windows.empty?
659
+
660
+ windows.sort_by! { |w| w[0] }
661
+
662
+ total = 0
663
+ cur_start, cur_end = windows[0]
664
+ windows[1..-1].each do |start, finish|
665
+ if start <= cur_end
666
+ cur_end = finish if finish > cur_end
667
+ else
668
+ total += (cur_end - cur_start).to_i
669
+ cur_start, cur_end = start, finish
620
670
  end
621
- return @remaining_days
622
671
  end
672
+ total += (cur_end - cur_start).to_i
673
+
674
+ total
623
675
  end
624
676
 
625
677
 
@@ -633,6 +685,7 @@ module AppManager
633
685
  charges = AppManager::Charge.where(status: 'active', shop_domain: params["shop_domain"])
634
686
  if charges.any?
635
687
  active_charge = charges.first.attributes
688
+ active_charge = active_charge.merge(effective_charge_pricing(active_charge))
636
689
  end
637
690
  cancelled_charges = AppManager::Charge.where(status: 'cancelled', shop_domain: params["shop_domain"]).order(created_at: :desc)
638
691
  if cancelled_charges.any?
@@ -643,6 +696,78 @@ module AppManager
643
696
  return charge_hash
644
697
  end
645
698
 
699
+ # Offline mirror of the portal's effective-pricing calculation. Derives the true
700
+ # price the shop pays right now from the discount snapshot stored on the charge
701
+ # plus elapsed time, instead of recomputing from current discount config.
702
+ def effective_charge_pricing(charge)
703
+ base = (charge['price'] || 0).to_f.round(2)
704
+ no_discount = {
705
+ 'effective_price' => base,
706
+ 'strike_price' => nil,
707
+ 'is_discount_active' => false,
708
+ 'remaining_intervals' => nil,
709
+ 'discount_ends_on' => nil
710
+ }
711
+
712
+ begin
713
+ now = DateTime.now
714
+
715
+ value = charge['discount_value']
716
+ return no_discount if value.nil? || value.to_f <= 0
717
+
718
+ type = charge['discount_type'] || 'percentage'
719
+ discounted = type == 'percentage' ? base - (base * value.to_f / 100) : base - value.to_f
720
+ discounted = [discounted, 0].max.round(2)
721
+
722
+ duration = (charge['discount_duration_intervals'] || 0).to_i
723
+ if duration <= 0
724
+ return {
725
+ 'effective_price' => discounted,
726
+ 'strike_price' => base,
727
+ 'is_discount_active' => true,
728
+ 'remaining_intervals' => nil,
729
+ 'discount_ends_on' => nil
730
+ }
731
+ end
732
+
733
+ anchor = to_datetime_safe(charge['trial_ends_on'] || charge['activated_on'] || charge['created_at']) || now
734
+
735
+ interval_days = charge['interval'] == 'ANNUAL' ? 365 : 30
736
+ days_since_anchor = [(now - anchor).to_f, 0].max
737
+ elapsed = (days_since_anchor / interval_days).floor
738
+ ends_on = (anchor + (duration * interval_days)).to_date.to_s
739
+
740
+ if elapsed >= duration
741
+ return {
742
+ 'effective_price' => base,
743
+ 'strike_price' => nil,
744
+ 'is_discount_active' => false,
745
+ 'remaining_intervals' => 0,
746
+ 'discount_ends_on' => ends_on
747
+ }
748
+ end
749
+
750
+ {
751
+ 'effective_price' => discounted,
752
+ 'strike_price' => base,
753
+ 'is_discount_active' => true,
754
+ 'remaining_intervals' => duration - elapsed,
755
+ 'discount_ends_on' => ends_on
756
+ }
757
+ rescue Exception => e
758
+ Rollbar.error("APP MANAGER effective_charge_pricing failed #{e.inspect}") rescue nil
759
+ no_discount
760
+ end
761
+ end
762
+
763
+ def to_datetime_safe(value)
764
+ return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
765
+ return value.to_datetime if value.respond_to?(:to_datetime)
766
+ DateTime.parse(value.to_s)
767
+ rescue Exception
768
+ nil
769
+ end
770
+
646
771
  def get_local_has_plan(params, options)
647
772
  if params["grandfathered"].present? && params["grandfathered"] == 1
648
773
  return {"has_plan" => true}
@@ -739,7 +864,9 @@ module AppManager
739
864
  test_value = charge["test"]
740
865
  plan_id = charge["plan_id"].to_i
741
866
  begin
742
- AppManager::Charge.create(charge_id: charge["charge_id"], test: test_value, status: charge["status"], name: charge["name"], type: charge["type"], price: charge["price"], interval: charge["interval"], trial_days: charge["trial_days"], billing_on: charge["billing_on"], activated_on: charge["activated_on"], trial_ends_on: charge["trial_ends_on"], cancelled_on: charge["cancelled_on"], expires_on: charge["expires_on"], plan_id: plan_id, description: charge["description"], shop_domain: charge["shop_domain"], created_at: charge["created_at"], updated_at: charge["updated_at"], sync: 0, process_type: 'store-charge')
867
+ attrs = { charge_id: charge["charge_id"], test: test_value, status: charge["status"], name: charge["name"], type: charge["type"], price: charge["price"], interval: charge["interval"], trial_days: charge["trial_days"], billing_on: charge["billing_on"], activated_on: charge["activated_on"], trial_ends_on: charge["trial_ends_on"], cancelled_on: charge["cancelled_on"], expires_on: charge["expires_on"], plan_id: plan_id, description: charge["description"], shop_domain: charge["shop_domain"], created_at: charge["created_at"], updated_at: charge["updated_at"], sync: 0, process_type: 'store-charge' }
868
+ add_charge_discount_snapshot(attrs, charge)
869
+ AppManager::Charge.create(attrs)
743
870
  message = {"message" => 'success'}
744
871
  rescue Exception => e
745
872
  Rollbar.error("Charge not saved on local DB due to #{e.inspect}")
@@ -1,6 +1,10 @@
1
1
  module AppManager
2
2
  class GraphqlHelper
3
-
3
+
4
+ # Discount snapshot captured while building the last recurring charge, so the
5
+ # charge callback can persist it (value/type/duration/source) on the charge row.
6
+ attr_reader :discount_snapshot
7
+
4
8
  def initialize(shopify_domain,shopify_token)
5
9
  @api_key = AppManager.configuration.shopify_api_key || nil
6
10
  @api_version = AppManager.configuration.shopify_api_version || nil
@@ -61,26 +65,13 @@ module AppManager
61
65
  if !plan['affiliate'].nil? && plan['affiliate'].any? && !shop_plan_field.nil? && (plan['affiliate'].map{|e| e['value']}.include?(shop[shop_plan_field]) || shop[partner_development_field])
62
66
  plan_test = true
63
67
  end
64
- trial_days = plan['trial_days'] || 0
65
- if shop && shop.shopify_domain && trial_days
68
+ trial_days = (plan['trial_days'] || 0).to_i
69
+ if shop && shop.shopify_domain
66
70
  trial_activated_at = shop[AppManager.configuration.field_names['trial_activated_at']] rescue nil
67
- plan_id_field = shop[AppManager.configuration.plan_id_or_name_field] rescue nil
68
71
  remaining_obj = AppManager::Client.new
69
- remaining = remaining_obj.get_remaining_days(shop.shopify_domain,trial_activated_at,plan_id_field)
70
- # trial_days = !remaining.nil? ? remaining : trial_days
71
-
72
- if !remaining.nil?
73
- if !plan_id_field.nil?
74
- plan_obj = AppManager::Client.new
75
- current_plan = plan_obj.get_plan(plan_id_field) rescue nil
76
- used_days = (current_plan['trial_days'].to_i - remaining.to_i) if current_plan rescue 0
77
- if used_days > 0
78
- days = trial_days - used_days
79
- trial_days = days > 0 ? days : 0
80
- end
81
- else
82
- trial_days = remaining
83
- end
72
+ remaining = remaining_obj.get_remaining_days(shop.shopify_domain, trial_activated_at, plan['id'])
73
+ if remaining.is_a?(Numeric) || (remaining.is_a?(String) && remaining.match?(/\A-?\d+\z/))
74
+ trial_days = [0, remaining.to_i].max
84
75
  end
85
76
  end
86
77
 
@@ -91,54 +82,61 @@ module AppManager
91
82
  promotional_discount = [] if promotional_discount.class.to_s == "Hash" && promotional_discount.has_key?('status') && promotional_discount['status'] == 404
92
83
  end
93
84
 
94
- if !plan['discount'].nil? && plan['discount'] != 0
85
+ # A promotional discount now takes priority over the plan's own discount when it
86
+ # applies to this (non-global) plan; otherwise fall back to the plan discount.
87
+ promo_applies = promotional_discount.present? && promotional_discount.respond_to?(:any?) && promotional_discount.any? &&
88
+ promotional_discount['value'].to_f > 0 &&
89
+ !(plan['is_global'] == 1 || plan['is_global'] == true) &&
90
+ (promotional_discount['plan_relation'].blank? || promotional_discount['plan_relation'].include?(plan['id']))
91
+
92
+ plan_discount = {}
93
+ @discount_snapshot = nil
94
+
95
+ if promo_applies
96
+ discount_type = promotional_discount['type'] || 'percentage'
97
+ discount_value = {
98
+ discount_type => discount_type == 'percentage' ? promotional_discount['value'].to_f / 100 : [promotional_discount['value'].to_f, plan['price']].min
99
+ }
100
+ discount = { 'value' => discount_value }
101
+ discount['durationLimitInIntervals'] = promotional_discount['duration_intervals'].to_i if promotional_discount['duration_intervals'].to_i.positive?
102
+ plan_discount = { "discount" => discount }
103
+
104
+ @discount_snapshot = {
105
+ 'discount_value' => promotional_discount['value'].to_f,
106
+ 'discount_type' => promotional_discount['type'] || 'percentage',
107
+ 'discount_duration_intervals' => promotional_discount['duration_intervals'].to_i,
108
+ 'discount_source' => 'promotional',
109
+ 'promotional_discount_id' => (promotional_discount['id'] || 0).to_i
110
+ }
111
+ elsif !plan['discount'].nil? && plan['discount'] != 0
95
112
  discount_type = plan['discount_type'] || "percentage"
96
113
  discount_val = discount_type == "percentage" ? (plan['discount'].to_f/ 100) : "#{plan['discount']}"
97
114
  if !@api_version.nil? && @api_version.to_s[/^\d{4}/].to_i >= 2024
98
- plan_discount = if plan['discount'] && plan['cycle_count'] && plan['cycle_count'].to_i != 0
115
+ plan_discount = if plan['cycle_count'] && plan['cycle_count'].to_i != 0
99
116
  {
100
117
  "discount" => { "durationLimitInIntervals" => (plan['cycle_count'].to_i),
101
118
  "value" => {"#{discount_type}" => discount_val}
102
119
  }
103
120
  }
104
- elsif plan['discount']
121
+ else
105
122
  {
106
123
  "discount" => { "value" => {"#{discount_type}" => discount_val}
107
124
  }
108
125
  }
109
- else
110
- {}
111
126
  end
112
127
  else
113
- plan_discount = plan['discount'] ? { "discount" => { "durationLimitInIntervals" => (plan['cycle_count'].to_i), "value" => {"#{discount_type}" => discount_val} } } : {}
114
- end
115
- else
116
-
117
- if promotional_discount.any?
118
-
119
- if promotional_discount['plan_relation'].any? && !promotional_discount['plan_relation'].include?(plan['id']) && plan['is_global']
120
- plan_discount = {}
121
- else
122
-
123
- discount_type = promotional_discount['type'] || 'percentage'
124
- discount_value = {
125
- discount_type => discount_type == 'percentage' ? promotional_discount['value'].to_f / 100 : [promotional_discount['value'].to_f, plan['price']].min
126
- }
127
-
128
- discount = { 'value' => discount_value }
129
-
130
- discount['durationLimitInIntervals'] = promotional_discount['duration_intervals'].to_i if promotional_discount['duration_intervals'].to_i.positive?
131
- plan_discount = { "discount" => discount}
132
-
133
- end
134
-
128
+ plan_discount = { "discount" => { "durationLimitInIntervals" => (plan['cycle_count'].to_i), "value" => {"#{discount_type}" => discount_val} } }
135
129
  end
136
130
 
131
+ @discount_snapshot = {
132
+ 'discount_value' => plan['discount'].to_f,
133
+ 'discount_type' => plan['discount_type'] || 'percentage',
134
+ 'discount_duration_intervals' => plan['cycle_count'].to_i,
135
+ 'discount_source' => plan['discount_is_custom'] ? 'custom' : 'plan'
136
+ }
137
137
  end
138
138
 
139
- discount_exists = !plan['discount'].nil? && plan['discount'] != 0
140
- promotional_discount_applies = promotional_discount.present? && promotional_discount.any?
141
- promotional_discount_id = discount_exists && promotional_discount_applies ? 0 : promotional_discount_applies ? promotional_discount['id'] : 0
139
+ promotional_discount_id = promo_applies ? (promotional_discount['id'] || 0) : 0
142
140
 
143
141
  return_url += "&discount=#{promotional_discount_id}"
144
142
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppManager
4
- VERSION = "2.5.0"
4
+ VERSION = "2.7.0"
5
5
  end
data/lib/app_manager.rb CHANGED
@@ -26,6 +26,12 @@ module AppManager
26
26
  @configuration ||= AppManager::Config.new
27
27
  end
28
28
 
29
+ # Cache key for the discount snapshot captured while a charge is being created.
30
+ # Kept under a separate prefix from "app-manager-cache/*" so clear_cache does not wipe it.
31
+ def self.charge_snapshot_cache_key(shop, timestamp)
32
+ "app-manager:charge-snapshot:#{shop}:#{timestamp}"
33
+ end
34
+
29
35
  def self.clear_cache
30
36
  begin
31
37
  Rails.cache.delete_matched("app-manager-cache/*")
@@ -82,6 +82,14 @@ module AppManager
82
82
  end
83
83
  end
84
84
 
85
+ def create_charge_discount_snapshot_and_used_migration
86
+ if self.class.migration_exists?("db/app_manager", "add_charge_discount_snapshot_and_used")
87
+ say_status("skipped", "Migration add_charge_discount_snapshot_and_used.rb already exists")
88
+ else
89
+ migration_template("add_charge_discount_snapshot_and_used.erb", "db/app_manager/add_charge_discount_snapshot_and_used.rb")
90
+ end
91
+ end
92
+
85
93
 
86
94
  private
87
95
 
@@ -0,0 +1,12 @@
1
+ class AddChargeDiscountSnapshotAndUsed < ActiveRecord::Migration[<%= rails_migration_version %>]
2
+ def change
3
+ add_column :charges, :discount_value, :decimal, precision: 8, scale: 2 unless column_exists?(:charges, :discount_value)
4
+ add_column :charges, :discount_type, :string unless column_exists?(:charges, :discount_type)
5
+ add_column :charges, :discount_duration_intervals, :integer unless column_exists?(:charges, :discount_duration_intervals)
6
+ add_column :charges, :discount_source, :string unless column_exists?(:charges, :discount_source)
7
+ add_column :charges, :promotional_discount_id, :bigint unless column_exists?(:charges, :promotional_discount_id)
8
+
9
+ add_column :discount_plans, :used, :boolean, default: false unless column_exists?(:discount_plans, :used)
10
+ add_column :plan_users, :used, :boolean, default: false unless column_exists?(:plan_users, :used)
11
+ end
12
+ end
@@ -29,6 +29,11 @@ class AddFailSafe < ActiveRecord::Migration[<%= rails_migration_version %>]
29
29
  t.string :charge_id
30
30
  t.boolean :test
31
31
  t.float :price
32
+ t.decimal :discount_value, precision: 8, scale: 2
33
+ t.string :discount_type
34
+ t.integer :discount_duration_intervals
35
+ t.string :discount_source
36
+ t.bigint :promotional_discount_id
32
37
  t.string :interval
33
38
  t.integer :trial_days
34
39
  t.datetime :billing_on
@@ -68,6 +73,7 @@ class AddFailSafe < ActiveRecord::Migration[<%= rails_migration_version %>]
68
73
  t.integer :created_by
69
74
  t.integer :app_id
70
75
  t.string :discount_type
76
+ t.boolean :used, default: false
71
77
  t.timestamps
72
78
  end
73
79
  create_table :extend_trials do |t|
@@ -84,6 +90,7 @@ class AddFailSafe < ActiveRecord::Migration[<%= rails_migration_version %>]
84
90
  t.integer :plan_id
85
91
  t.integer :app_id
86
92
  t.integer :created_by
93
+ t.boolean :used, default: false
87
94
  t.timestamps
88
95
  end
89
96
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: app_manager
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rahul Tiwari @ Hulkapps
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-29 00:00:00.000000000 Z
11
+ date: 2026-07-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httparty
@@ -125,6 +125,7 @@ files:
125
125
  - lib/app_manager/tasks/sync/local_app_manager.rake
126
126
  - lib/app_manager/version.rb
127
127
  - lib/generators/app_manager/install/install_generator.rb
128
+ - lib/generators/app_manager/install/templates/add_charge_discount_snapshot_and_used.erb
128
129
  - lib/generators/app_manager/install/templates/add_discount_tables.erb
129
130
  - lib/generators/app_manager/install/templates/add_external_charge_field.erb
130
131
  - lib/generators/app_manager/install/templates/add_is_global_to_plans.erb