pricing_plans 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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +16 -0
  3. data/.rubocop.yml +137 -0
  4. data/CHANGELOG.md +83 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +241 -0
  7. data/Rakefile +15 -0
  8. data/docs/01-define-pricing-plans.md +372 -0
  9. data/docs/02-controller-helpers.md +223 -0
  10. data/docs/03-model-helpers.md +318 -0
  11. data/docs/04-views.md +121 -0
  12. data/docs/05-semantic-pricing.md +159 -0
  13. data/docs/06-gem-compatibility.md +99 -0
  14. data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
  15. data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
  16. data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
  17. data/docs/images/product_creation_blocked.jpg +0 -0
  18. data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
  19. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
  20. data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
  21. data/lib/pricing_plans/association_limit_registry.rb +45 -0
  22. data/lib/pricing_plans/configuration.rb +189 -0
  23. data/lib/pricing_plans/controller_guards.rb +574 -0
  24. data/lib/pricing_plans/controller_rescues.rb +115 -0
  25. data/lib/pricing_plans/dsl.rb +44 -0
  26. data/lib/pricing_plans/engine.rb +69 -0
  27. data/lib/pricing_plans/grace_manager.rb +227 -0
  28. data/lib/pricing_plans/integer_refinements.rb +48 -0
  29. data/lib/pricing_plans/job_guards.rb +24 -0
  30. data/lib/pricing_plans/limit_checker.rb +157 -0
  31. data/lib/pricing_plans/limitable.rb +286 -0
  32. data/lib/pricing_plans/models/assignment.rb +55 -0
  33. data/lib/pricing_plans/models/enforcement_state.rb +45 -0
  34. data/lib/pricing_plans/models/usage.rb +51 -0
  35. data/lib/pricing_plans/overage_reporter.rb +77 -0
  36. data/lib/pricing_plans/pay_support.rb +85 -0
  37. data/lib/pricing_plans/period_calculator.rb +183 -0
  38. data/lib/pricing_plans/plan.rb +653 -0
  39. data/lib/pricing_plans/plan_owner.rb +287 -0
  40. data/lib/pricing_plans/plan_resolver.rb +85 -0
  41. data/lib/pricing_plans/price_components.rb +16 -0
  42. data/lib/pricing_plans/registry.rb +182 -0
  43. data/lib/pricing_plans/result.rb +109 -0
  44. data/lib/pricing_plans/version.rb +5 -0
  45. data/lib/pricing_plans/view_helpers.rb +58 -0
  46. data/lib/pricing_plans.rb +645 -0
  47. data/sig/pricing_plans.rbs +4 -0
  48. metadata +236 -0
@@ -0,0 +1,653 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "integer_refinements"
4
+
5
+ module PricingPlans
6
+ class Plan
7
+ using IntegerRefinements
8
+
9
+ attr_reader :key, :features
10
+
11
+ def initialize(key)
12
+ @key = key
13
+ @name = nil
14
+ @description = nil
15
+ @bullets = []
16
+ @price = nil
17
+ @price_string = nil
18
+ @stripe_price = nil
19
+ @features = Set.new
20
+ @limits = {}
21
+ @credits_included = nil
22
+ @meta = {}
23
+ @cta_text = nil
24
+ @cta_url = nil
25
+ @default = false
26
+ @highlighted = false
27
+ end
28
+
29
+ # DSL methods for plan configuration
30
+ def set_name(value)
31
+ @name = value.to_s
32
+ end
33
+
34
+ def name(value = nil)
35
+ if value.nil?
36
+ @name || @key.to_s.titleize
37
+ else
38
+ set_name(value)
39
+ end
40
+ end
41
+
42
+ def set_description(value)
43
+ @description = value.to_s
44
+ end
45
+
46
+ def description(value = nil)
47
+ if value.nil?
48
+ @description
49
+ else
50
+ set_description(value)
51
+ end
52
+ end
53
+
54
+ def set_bullets(*values)
55
+ @bullets = values.flatten.map(&:to_s)
56
+ end
57
+
58
+ def bullets(*values)
59
+ if values.empty?
60
+ @bullets
61
+ else
62
+ set_bullets(*values)
63
+ end
64
+ end
65
+
66
+ def set_price(value)
67
+ @price = value
68
+ end
69
+
70
+ def price(value = nil)
71
+ if value.nil?
72
+ @price
73
+ else
74
+ set_price(value)
75
+ end
76
+ end
77
+
78
+ # Rails-y ergonomics for UI: expose integer cents as optional helper
79
+ def price_cents
80
+ return nil unless @price
81
+ (
82
+ if @price.respond_to?(:to_f)
83
+ (@price.to_f * 100).round
84
+ else
85
+ nil
86
+ end
87
+ )
88
+ end
89
+
90
+ # Ergonomic predicate for UI/logic (free means explicit 0 price or explicit "Free" label)
91
+ def free?
92
+ return false if @stripe_price
93
+ return true if @price.respond_to?(:to_i) && @price.to_i.zero?
94
+ return true if @price_string && @price_string.to_s.strip.casecmp("Free").zero?
95
+ false
96
+ end
97
+
98
+ def set_price_string(value)
99
+ @price_string = value.to_s
100
+ end
101
+
102
+ def price_string(value = nil)
103
+ if value.nil?
104
+ @price_string
105
+ else
106
+ set_price_string(value)
107
+ end
108
+ end
109
+
110
+ def set_stripe_price(value)
111
+ case value
112
+ when String
113
+ @stripe_price = { id: value }
114
+ when Hash
115
+ @stripe_price = value
116
+ else
117
+ raise ConfigurationError, "stripe_price must be a string or hash"
118
+ end
119
+ end
120
+
121
+ def stripe_price(value = nil)
122
+ if value.nil?
123
+ @stripe_price
124
+ else
125
+ set_stripe_price(value)
126
+ end
127
+ end
128
+
129
+ def set_meta(values)
130
+ @meta.merge!(values)
131
+ end
132
+
133
+ def meta(values = nil)
134
+ if values.nil?
135
+ @meta
136
+ else
137
+ set_meta(values)
138
+ end
139
+ end
140
+
141
+ # CTA helpers for pricing UI
142
+ def set_cta_text(value)
143
+ @cta_text = value&.to_s
144
+ end
145
+
146
+ def cta_text(value = nil)
147
+ if value.nil?
148
+ @cta_text || PricingPlans.configuration.default_cta_text || default_cta_text_derived
149
+ else
150
+ set_cta_text(value)
151
+ end
152
+ end
153
+
154
+ def set_cta_url(value)
155
+ @cta_url = value&.to_s
156
+ end
157
+
158
+ # Unified ergonomic API:
159
+ # - Setter/getter: cta_url, cta_url("/checkout")
160
+ # - Resolver: cta_url(plan_owner: org)
161
+ def cta_url(value = :__no_arg__, plan_owner: nil)
162
+ unless value == :__no_arg__
163
+ set_cta_url(value)
164
+ return @cta_url
165
+ end
166
+
167
+ return @cta_url if @cta_url
168
+ default = PricingPlans.configuration.default_cta_url
169
+ return default if default
170
+ # New default: if host app defines subscribe_path, prefer that
171
+ if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
172
+ return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
173
+ end
174
+ nil
175
+ end
176
+
177
+ # Feature methods
178
+ def allows(*feature_keys)
179
+ feature_keys.flatten.each do |key|
180
+ @features.add(key.to_sym)
181
+ end
182
+ end
183
+
184
+ def allow(*feature_keys)
185
+ allows(*feature_keys)
186
+ end
187
+
188
+ def disallows(*feature_keys)
189
+ feature_keys.flatten.each do |key|
190
+ @features.delete(key.to_sym)
191
+ end
192
+ end
193
+
194
+ def disallow(*feature_keys)
195
+ disallows(*feature_keys)
196
+ end
197
+
198
+ def allows_feature?(feature_key)
199
+ @features.include?(feature_key.to_sym)
200
+ end
201
+
202
+ # Limit methods
203
+ def set_limit(key, **options)
204
+ limit_key = key.to_sym
205
+ @limits[limit_key] = {
206
+ key: limit_key,
207
+ to: options[:to],
208
+ per: options[:per],
209
+ after_limit: options.fetch(:after_limit, :block_usage),
210
+ grace: options.fetch(:grace, 7.days),
211
+ warn_at: options.fetch(:warn_at, [0.6, 0.8, 0.95]),
212
+ count_scope: options[:count_scope]
213
+ }
214
+
215
+ validate_limit_options!(@limits[limit_key])
216
+ end
217
+
218
+ def limits(key=nil, **options)
219
+ if key.nil?
220
+ @limits
221
+ else
222
+ set_limit(key, **options)
223
+ end
224
+ end
225
+
226
+ def limit(key, **options)
227
+ set_limit(key, **options)
228
+ end
229
+
230
+ def unlimited(*keys)
231
+ keys.flatten.each do |key|
232
+ set_limit(key.to_sym, to: :unlimited)
233
+ end
234
+ end
235
+
236
+ def limit_for(key)
237
+ @limits[key.to_sym]
238
+ end
239
+
240
+ # Credits display methods (cosmetic, for pricing UI)
241
+ # Single-currency credits. We do not tie credits to operations here.
242
+ def includes_credits(amount)
243
+ @credits_included = amount.to_i
244
+ end
245
+
246
+ def credits_included(value = :__get__)
247
+ if value == :__get__
248
+ @credits_included
249
+ else
250
+ @credits_included = value.to_i
251
+ end
252
+ end
253
+
254
+ # Plan selection sugar
255
+ def default!(value = true)
256
+ @default = !!value
257
+ end
258
+
259
+ def default?
260
+ !!@default
261
+ end
262
+
263
+ def highlighted!(value = true)
264
+ @highlighted = !!value
265
+ end
266
+
267
+ def highlighted?
268
+ return true if @highlighted
269
+ # Treat configuration.highlighted_plan as highlighted without consulting Registry to avoid recursion
270
+ begin
271
+ cfg = PricingPlans.configuration
272
+ return true if cfg && cfg.highlighted_plan && cfg.highlighted_plan.to_sym == @key
273
+ rescue StandardError
274
+ end
275
+ false
276
+ end
277
+
278
+ # Syntactic sugar for popular/highlighted
279
+ def popular?
280
+ highlighted?
281
+ end
282
+
283
+ # Convenience booleans used by views/hosts
284
+ # (keep single definition above)
285
+
286
+ def purchasable?
287
+ !!@stripe_price || (!free? && !!@price)
288
+ end
289
+
290
+ # Human label to display price in UIs. Prefers explicit string, then numeric, else contact.
291
+ def price_label
292
+ # Auto-fetch from processor (Stripe) if enabled and plan has stripe_price
293
+ cfg = PricingPlans.configuration
294
+ if cfg&.auto_price_labels_from_processor && stripe_price
295
+ begin
296
+ if defined?(::Stripe)
297
+ price_id = stripe_price.is_a?(Hash) ? (stripe_price[:id] || stripe_price[:month] || stripe_price[:year]) : stripe_price
298
+ if price_id
299
+ pr = ::Stripe::Price.retrieve(price_id)
300
+ amount = pr.unit_amount.to_f / 100.0
301
+ interval = pr.recurring&.interval
302
+ suffix = interval ? "/#{interval[0,3]}" : ""
303
+ return "$#{amount}#{suffix}"
304
+ end
305
+ end
306
+ rescue StandardError
307
+ # fallthrough to local derivation
308
+ end
309
+ end
310
+ # Allow host app override via resolver
311
+ if cfg&.price_label_resolver
312
+ begin
313
+ built = cfg.price_label_resolver.call(self)
314
+ return built if built
315
+ rescue StandardError
316
+ end
317
+ end
318
+ return "Free" if price && price.to_i.zero?
319
+ return price_string if price_string
320
+ return "$#{price}/mo" if price
321
+ return "Contact" if stripe_price || price.nil?
322
+ nil
323
+ end
324
+
325
+ # --- New semantic pricing API ---
326
+
327
+ # Compute semantic price parts for the given interval (:month or :year).
328
+ # Falls back to price_string when no numeric price exists.
329
+ def price_components(interval: :month)
330
+ # 1) Allow app override
331
+ if (resolver = PricingPlans.configuration.price_components_resolver)
332
+ begin
333
+ resolved = resolver.call(self, interval)
334
+ return resolved if resolved
335
+ rescue StandardError
336
+ end
337
+ end
338
+
339
+ # 2) String-only prices
340
+ if price_string
341
+ return PricingPlans::PriceComponents.new(
342
+ present?: false,
343
+ currency: nil,
344
+ amount: nil,
345
+ amount_cents: nil,
346
+ interval: interval,
347
+ label: price_string,
348
+ monthly_equivalent_cents: nil
349
+ )
350
+ end
351
+
352
+ # 3) Explicit numeric price (single interval, assume monthly semantics)
353
+ if price
354
+ cents = price_cents
355
+ cur = PricingPlans.configuration.default_currency_symbol
356
+ label = if interval == :month
357
+ "#{cur}#{price}/mo"
358
+ else
359
+ # Treat yearly as 12x when only a single numeric price is declared
360
+ "#{cur}#{(price.to_f * 12).round}/yr"
361
+ end
362
+ return PricingPlans::PriceComponents.new(
363
+ present?: true,
364
+ currency: cur,
365
+ amount: (interval == :month ? price.to_i : (price.to_f * 12).round).to_s,
366
+ amount_cents: (interval == :month ? cents : (cents.to_i * 12)),
367
+ interval: interval,
368
+ label: label,
369
+ monthly_equivalent_cents: cents
370
+ )
371
+ end
372
+
373
+ # 4) Stripe price(s)
374
+ if stripe_price
375
+ comp = stripe_price_components(interval)
376
+ return comp if comp
377
+ end
378
+
379
+ # 5) No price info at all → Contact
380
+ PricingPlans::PriceComponents.new(
381
+ present?: false,
382
+ currency: nil,
383
+ amount: nil,
384
+ amount_cents: nil,
385
+ interval: interval,
386
+ label: "Contact",
387
+ monthly_equivalent_cents: nil
388
+ )
389
+ end
390
+
391
+ def monthly_price_components
392
+ price_components(interval: :month)
393
+ end
394
+
395
+ def yearly_price_components
396
+ price_components(interval: :year)
397
+ end
398
+
399
+ def has_interval_prices?
400
+ sp = stripe_price
401
+ return true if sp.is_a?(Hash) && (sp[:month] || sp[:year])
402
+ return !price.nil? || !price_string.nil?
403
+ end
404
+
405
+ def has_numeric_price?
406
+ !!price || !!stripe_price
407
+ end
408
+
409
+ def price_label_for(interval)
410
+ pc = price_components(interval: interval)
411
+ pc.label
412
+ end
413
+
414
+ # Stripe convenience accessors (nil when interval not present)
415
+ def monthly_price_cents
416
+ pc = monthly_price_components
417
+ pc.present? ? pc.amount_cents : nil
418
+ end
419
+
420
+ def yearly_price_cents
421
+ pc = yearly_price_components
422
+ pc.present? ? pc.amount_cents : nil
423
+ end
424
+
425
+ def monthly_price_id
426
+ stripe_price_id_for(:month)
427
+ end
428
+
429
+ def yearly_price_id
430
+ stripe_price_id_for(:year)
431
+ end
432
+
433
+ def currency_symbol
434
+ if stripe_price
435
+ # Try to derive from Stripe API/cache; fall back to default
436
+ pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
437
+ if pr
438
+ return currency_symbol_from(pr)
439
+ end
440
+ end
441
+ PricingPlans.configuration.default_currency_symbol
442
+ end
443
+
444
+ # Plan comparison helpers for CTA ergonomics
445
+ def current_for?(current_plan)
446
+ return false unless current_plan
447
+ current_plan.key.to_sym == key.to_sym
448
+ end
449
+
450
+ def upgrade_from?(current_plan)
451
+ return false unless current_plan
452
+ comparable_price_cents(self) > comparable_price_cents(current_plan)
453
+ end
454
+
455
+ def downgrade_from?(current_plan)
456
+ return false unless current_plan
457
+ comparable_price_cents(self) < comparable_price_cents(current_plan)
458
+ end
459
+
460
+ def downgrade_blocked_reason(from: nil, plan_owner: nil)
461
+ return nil unless from
462
+ allowed, reason = PricingPlans.configuration.downgrade_policy.call(from: from, to: self, plan_owner: plan_owner)
463
+ allowed ? nil : (reason || "Downgrade not allowed")
464
+ end
465
+
466
+ # Pure-data view model for JS/Hotwire
467
+ def to_view_model
468
+ {
469
+ id: key.to_s,
470
+ key: key.to_s,
471
+ name: name,
472
+ description: description,
473
+ features: bullets, # alias in this gem
474
+ highlighted: highlighted?,
475
+ default: default?,
476
+ free: free?,
477
+ currency: currency_symbol,
478
+ monthly_price_cents: monthly_price_cents,
479
+ yearly_price_cents: yearly_price_cents,
480
+ monthly_price_id: monthly_price_id,
481
+ yearly_price_id: yearly_price_id,
482
+ price_label: price_label,
483
+ price_string: price_string,
484
+ limits: limits.transform_values { |v| v.dup }
485
+ }
486
+ end
487
+
488
+ def validate!
489
+ validate_limits!
490
+ validate_pricing!
491
+ end
492
+
493
+ private
494
+ def validate_limits!
495
+ @limits.each do |key, limit|
496
+ validate_limit_options!(limit)
497
+ end
498
+ end
499
+
500
+ def validate_limit_options!(limit)
501
+ # Validate to: value
502
+ unless limit[:to] == :unlimited || limit[:to].is_a?(Integer) || (limit[:to].respond_to?(:to_i) && !limit[:to].is_a?(String))
503
+ raise ConfigurationError, "Limit #{limit[:key]} 'to' must be :unlimited, Integer, or respond to to_i"
504
+ end
505
+
506
+ # Validate after_limit values
507
+ valid_after_limit = [:grace_then_block, :block_usage, :just_warn]
508
+ unless valid_after_limit.include?(limit[:after_limit])
509
+ raise ConfigurationError, "Limit #{limit[:key]} after_limit must be one of #{valid_after_limit.join(', ')}"
510
+ end
511
+
512
+ # Validate grace only applies to blocking behaviors
513
+ if limit[:grace] && limit[:after_limit] == :just_warn
514
+ raise ConfigurationError, "Limit #{limit[:key]} cannot have grace with :just_warn after_limit"
515
+ end
516
+
517
+ # Validate warn_at thresholds
518
+ if limit[:warn_at] && !limit[:warn_at].all? { |t| t.is_a?(Numeric) && t.between?(0, 1) }
519
+ raise ConfigurationError, "Limit #{limit[:key]} warn_at thresholds must be numbers between 0 and 1"
520
+ end
521
+
522
+ # Validate count_scope only for persistent caps (no per-period)
523
+ if limit[:count_scope] && limit[:per]
524
+ raise ConfigurationError, "Limit #{limit[:key]} cannot set count_scope for per-period limits"
525
+ end
526
+ if limit[:count_scope]
527
+ cs = limit[:count_scope]
528
+ allowed = cs.respond_to?(:call) || cs.is_a?(Symbol) || cs.is_a?(Hash) || (cs.is_a?(Array) && cs.all? { |e| e.respond_to?(:call) || e.is_a?(Symbol) || e.is_a?(Hash) })
529
+ raise ConfigurationError, "Limit #{limit[:key]} count_scope must be a Proc, Symbol, Hash, or Array of these" unless allowed
530
+ end
531
+ end
532
+
533
+ def validate_pricing!
534
+ pricing_fields = [@price, @price_string, @stripe_price].compact
535
+ if pricing_fields.size > 1
536
+ raise ConfigurationError, "Plan #{@key} can only have one of: price, price_string, or stripe_price"
537
+ end
538
+ end
539
+
540
+ # (cta_url resolver moved above with unified signature)
541
+
542
+ def default_cta_text_derived
543
+ return "Subscribe" if @stripe_price
544
+ return "Choose #{@name || @key.to_s.titleize}" if price || price_string
545
+ return "Choose plan" if @stripe_price.nil? && !price && !price_string
546
+ "Choose #{@name || @key.to_s.titleize}"
547
+ end
548
+
549
+ def default_cta_url_derived
550
+ # If Stripe price present and Pay is used, UIs commonly route to checkout; we leave URL blank for app to decide.
551
+ nil
552
+ end
553
+
554
+ # --- Internal helpers for Stripe fetching and caching ---
555
+
556
+ def stripe_price_id_for(interval)
557
+ sp = stripe_price
558
+ case sp
559
+ when Hash
560
+ case interval
561
+ when :month then sp[:month] || sp[:id]
562
+ when :year then sp[:year]
563
+ else sp[:id]
564
+ end
565
+ when String
566
+ sp
567
+ else
568
+ nil
569
+ end
570
+ end
571
+
572
+ def preferred_price_id(interval)
573
+ stripe_price_id_for(interval)
574
+ end
575
+
576
+ def stripe_price_components(interval)
577
+ return nil unless defined?(::Stripe)
578
+ price_id = preferred_price_id(interval)
579
+ return nil unless price_id
580
+ pr = fetch_stripe_price_record(price_id)
581
+ return nil unless pr
582
+ amount_cents = (pr.unit_amount || pr.unit_amount_decimal || 0).to_i
583
+ interval_sym = (pr.recurring&.interval == "year" ? :year : :month)
584
+ cur = currency_symbol_from(pr)
585
+ label = "#{cur}#{(amount_cents / 100.0).round}/#{interval_sym == :year ? 'yr' : 'mo'}"
586
+ monthly_equiv = interval_sym == :month ? amount_cents : (amount_cents / 12.0).round
587
+ PricingPlans::PriceComponents.new(
588
+ present?: true,
589
+ currency: cur,
590
+ amount: ((amount_cents / 100.0).round).to_i.to_s,
591
+ amount_cents: amount_cents,
592
+ interval: interval_sym,
593
+ label: label,
594
+ monthly_equivalent_cents: monthly_equiv
595
+ )
596
+ rescue StandardError
597
+ nil
598
+ end
599
+
600
+ # Normalize a plan into a comparable monthly price in cents for upgrades/downgrades
601
+ def comparable_price_cents(plan)
602
+ return 0 if plan.free?
603
+ pcm = plan.monthly_price_cents
604
+ return pcm if pcm
605
+ pcy = plan.yearly_price_cents
606
+ return (pcy.to_f / 12.0).round if pcy
607
+ 0
608
+ end
609
+
610
+ def currency_symbol_from(price_record)
611
+ code = price_record.try(:currency).to_s.upcase
612
+ case code
613
+ when "USD" then "$"
614
+ when "EUR" then "€"
615
+ when "GBP" then "£"
616
+ else PricingPlans.configuration.default_currency_symbol
617
+ end
618
+ end
619
+
620
+ def fetch_stripe_price_record(price_id)
621
+ cfg = PricingPlans.configuration
622
+ cache = cfg.price_cache
623
+ cache_key = ["pricing_plans", "stripe_price", price_id].join(":")
624
+ if cache
625
+ cached = safe_cache_read(cache, cache_key)
626
+ return cached if cached
627
+ end
628
+ pr = ::Stripe::Price.retrieve(price_id)
629
+ if cache
630
+ safe_cache_write(cache, cache_key, pr, expires_in: cfg.price_cache_ttl)
631
+ end
632
+ pr
633
+ end
634
+
635
+ def safe_cache_read(cache, key)
636
+ cache.respond_to?(:read) ? cache.read(key) : nil
637
+ rescue StandardError
638
+ nil
639
+ end
640
+
641
+ def safe_cache_write(cache, key, value, expires_in: nil)
642
+ if cache.respond_to?(:write)
643
+ if expires_in
644
+ cache.write(key, value, expires_in: expires_in)
645
+ else
646
+ cache.write(key, value)
647
+ end
648
+ end
649
+ rescue StandardError
650
+ # ignore cache errors
651
+ end
652
+ end
653
+ end