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.
- checksums.yaml +4 -4
- data/README.md +442 -102
- data/lib/superfeature/core_ext/numeric.rb +19 -0
- data/lib/superfeature/core_ext/string.rb +12 -0
- data/lib/superfeature/core_ext.rb +12 -0
- data/lib/superfeature/discount.rb +171 -11
- data/lib/superfeature/engine.rb +4 -0
- data/lib/superfeature/plan/collection.rb +14 -14
- data/lib/superfeature/plan.rb +2 -2
- data/lib/superfeature/price.rb +316 -61
- data/lib/superfeature/round.rb +83 -0
- data/lib/superfeature/version.rb +1 -1
- data/lib/superfeature.rb +20 -7
- metadata +6 -2
|
@@ -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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
data/lib/superfeature/engine.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
data/lib/superfeature/plan.rb
CHANGED
|
@@ -39,8 +39,8 @@ module Superfeature
|
|
|
39
39
|
Feature.new(*, **, &)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def enable(
|
|
43
|
-
feature(*, **, limit: Limit::Boolean.new(enabled:
|
|
42
|
+
def enable(*, **)
|
|
43
|
+
feature(*, **, limit: Limit::Boolean.new(enabled: true))
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def disable(*, **)
|