superfeature 0.1.7 → 0.1.8

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.
@@ -1,44 +1,204 @@
1
+ require 'bigdecimal'
2
+
1
3
  module Superfeature
4
+ # Discount types that can be applied to a Price.
5
+ #
6
+ # Price(100).apply_discount(Discount::Percent.new(20)) # => 80
7
+ # Price(100).apply_discount(Discount::Fixed.new(15)) # => 85
8
+ #
2
9
  module Discount
10
+ # Base class for all discount types
3
11
  class Base
12
+ # Allows any discount to be passed to apply_discount
4
13
  def to_discount = self
14
+
15
+ private
16
+
17
+ def to_decimal(value)
18
+ case value
19
+ when BigDecimal then value
20
+ when Float then BigDecimal(value, 15)
21
+ else BigDecimal(value.to_s)
22
+ end
23
+ end
24
+ end
25
+
26
+ # Wraps a discount after it's been applied to a price.
27
+ # Holds the computed savings (fixed and percent) for display.
28
+ class Applied
29
+ attr_reader :source, :fixed, :percent
30
+
31
+ def initialize(source, fixed:, percent:)
32
+ @source = source
33
+ @fixed = fixed
34
+ @percent = percent
35
+ end
36
+
37
+ def to_formatted_s = source.to_formatted_s
38
+ def to_receipt_s = source.to_receipt_s
39
+ def none? = false
40
+
41
+ def to_fixed_s(decimals: 2)
42
+ "%.#{decimals}f" % fixed.abs.to_f
43
+ end
44
+
45
+ def to_percent_s(decimals: 0)
46
+ decimals.zero? ? "#{percent.to_i}%" : "%.#{decimals}f%%" % percent.to_f
47
+ end
48
+
49
+ def amount
50
+ source.amount if source.respond_to?(:amount)
51
+ end
52
+ end
53
+
54
+ # Null object for when no discount is applied.
55
+ # Allows safe method chaining without nil checks.
56
+ class None
57
+ def source = nil
58
+ def fixed = BigDecimal("0")
59
+ def percent = BigDecimal("0")
60
+ def amount = nil
61
+ def to_formatted_s = ""
62
+ def to_receipt_s = "Discount"
63
+ def none? = true
64
+
65
+ def to_fixed_s(decimals: 2)
66
+ "%.#{decimals}f" % 0
67
+ end
68
+
69
+ def to_percent_s(decimals: 0)
70
+ decimals.zero? ? "0%" : "%.#{decimals}f%%" % 0
71
+ end
72
+ end
73
+
74
+ NONE = None.new.freeze
75
+
76
+ # Cumulative savings from original price
77
+ class Savings
78
+ attr_reader :fixed, :percent
79
+
80
+ def initialize(fixed:, percent:)
81
+ @fixed = fixed
82
+ @percent = percent
83
+ end
84
+
85
+ def to_fixed_s(decimals: 2)
86
+ "%.#{decimals}f" % fixed.abs.to_f
87
+ end
88
+
89
+ def to_percent_s(decimals: 0)
90
+ decimals.zero? ? "#{percent.abs.to_i}%" : "%.#{decimals}f%%" % percent.abs.to_f
91
+ end
92
+
93
+ def none?
94
+ fixed.zero?
95
+ end
96
+
97
+ def to_s
98
+ to_fixed_s
99
+ end
5
100
  end
6
101
 
102
+ # Fixed dollar amount discount (e.g., "$20 off your first month")
7
103
  class Fixed < Base
104
+ PATTERN = /\A\$?\s*(\d+(?:\.\d+)?)\z/
105
+
106
+ def self.parse(str)
107
+ str.match(PATTERN) { |m| new(BigDecimal(m.captures.first)) }
108
+ end
109
+
8
110
  attr_reader :amount
9
111
 
10
112
  def initialize(amount)
11
- @amount = amount
113
+ @amount = to_decimal(amount)
12
114
  end
13
115
 
14
- def apply(price) = price - amount
116
+ def to_formatted_s = @amount.to_i.to_s
117
+ def to_receipt_s = "%.2f off" % @amount.to_f
118
+
119
+ def apply(price)
120
+ to_decimal(price) - @amount
121
+ end
15
122
  end
16
123
 
124
+ # Percentage discount (e.g., "20% off annual plans")
17
125
  class Percent < Base
126
+ PATTERN = /\A(\d+(?:\.\d+)?)\s*%\z/
127
+
128
+ def self.parse(str)
129
+ str.match(PATTERN) { |m| new(BigDecimal(m.captures.first)) }
130
+ end
131
+
18
132
  attr_reader :percent
19
133
 
20
134
  def initialize(percent)
21
- @percent = percent
135
+ @percent = to_decimal(percent)
22
136
  end
23
137
 
24
- def apply(price) = price * (1 - percent / 100.0)
138
+ def to_formatted_s = "#{@percent.to_i}%"
139
+ def to_receipt_s = "#{@percent.to_i}% off"
140
+
141
+ def apply(price)
142
+ to_decimal(price) * (1 - @percent / 100)
143
+ end
25
144
  end
26
145
 
27
- class Bundle < Base
28
- attr_reader :discounts
146
+ # Rounding discounts.
147
+ # Rounds price to a specified ending like .99 or 9.
148
+ #
149
+ # Round::Up.new(9).apply(50) # => 59
150
+ # Round::Down.new(9).apply(50) # => 49
151
+ # Round::Nearest.new(9).apply(50) # => 49
152
+ #
153
+ module Round
154
+ class Up < Base
155
+ attr_reader :ending
29
156
 
30
- def initialize(*discounts)
31
- @discounts = discounts.flatten
157
+ def initialize(ending)
158
+ @ending = to_decimal(ending)
159
+ end
160
+
161
+ def apply(price)
162
+ Superfeature::Round.new(@ending).up(price)
163
+ end
164
+
165
+ def to_formatted_s = "Round up"
166
+ def to_receipt_s = "Round up"
32
167
  end
33
168
 
34
- def apply(price)
35
- discounts.reduce(price) { |amt, d| d.to_discount.apply(amt) }
169
+ class Down < Base
170
+ attr_reader :ending
171
+
172
+ def initialize(ending)
173
+ @ending = to_decimal(ending)
174
+ end
175
+
176
+ def apply(price)
177
+ Superfeature::Round.new(@ending).down(price)
178
+ end
179
+
180
+ def to_formatted_s = "Round down"
181
+ def to_receipt_s = "Round down"
182
+ end
183
+
184
+ class Nearest < Base
185
+ attr_reader :ending
186
+
187
+ def initialize(ending)
188
+ @ending = to_decimal(ending)
189
+ end
190
+
191
+ def apply(price)
192
+ Superfeature::Round.new(@ending).nearest(price)
193
+ end
194
+
195
+ def to_formatted_s = "Round"
196
+ def to_receipt_s = "Round"
36
197
  end
37
198
  end
38
199
 
39
200
  # Convenience methods: Discount::Fixed(20) instead of Discount::Fixed.new(20)
40
201
  def self.Fixed(amount) = Fixed.new(amount)
41
202
  def self.Percent(percent) = Percent.new(percent)
42
- def self.Bundle(...) = Bundle.new(...)
43
203
  end
44
204
  end
@@ -1,5 +1,9 @@
1
1
  module Superfeature
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Superfeature
4
+
5
+ initializer "superfeature.core_ext" do
6
+ require "superfeature/core_ext"
7
+ end
4
8
  end
5
9
  end
@@ -24,22 +24,10 @@ module Superfeature
24
24
  keys.filter_map { |key| find(key) }
25
25
  end
26
26
 
27
- private
28
-
29
- def next_plan
30
- return nil unless @plan.class.method_defined?(:next, false)
31
- @plan.next
32
- end
33
-
34
- def previous_plan
35
- return nil unless @plan.class.method_defined?(:previous, false)
36
- @plan.previous
37
- end
38
-
39
27
  def upgrades
40
28
  Enumerator.new do |y|
41
29
  node = @plan
42
- while (node = node.class.method_defined?(:next, false) ? node.next : nil)
30
+ while (node = next_plan(node))
43
31
  y << node
44
32
  end
45
33
  end
@@ -49,13 +37,25 @@ module Superfeature
49
37
  Enumerator.new do |y|
50
38
  node = @plan
51
39
  nodes = []
52
- while (node = node.class.method_defined?(:previous, false) ? node.previous : nil)
40
+ while (node = previous_plan(node))
53
41
  nodes.unshift(node)
54
42
  end
55
43
  nodes.each { |n| y << n }
56
44
  end
57
45
  end
58
46
 
47
+ private
48
+
49
+ def next_plan(node)
50
+ return nil unless node.class.method_defined?(:next, false)
51
+ node.next
52
+ end
53
+
54
+ def previous_plan(node)
55
+ return nil unless node.class.method_defined?(:previous, false)
56
+ node.previous
57
+ end
58
+
59
59
  def normalize_key(key)
60
60
  case key
61
61
  when Class
@@ -39,8 +39,8 @@ module Superfeature
39
39
  Feature.new(*, **, &)
40
40
  end
41
41
 
42
- def enable(flag = true, *, **)
43
- feature(*, **, limit: Limit::Boolean.new(enabled: flag))
42
+ def enable(*, **)
43
+ feature(*, **, limit: Limit::Boolean.new(enabled: true))
44
44
  end
45
45
 
46
46
  def disable(*, **)