superfeature 0.1.5 → 0.1.7

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: 8fc5708efe44ee4472cc5f8ff78f3e7351e5f605e50828571db62f7919da128c
4
- data.tar.gz: d217d3329d95a792f988640e0512b5a23cc8b77f62fbd03af5e26d2e87096106
3
+ metadata.gz: 49aa8d4e0fec4908fb0ef75f2775154ed4824e79565ffde44f39ccbf723f0e7f
4
+ data.tar.gz: e9e7e20b7eb9c9ac39baeeb68d1311ccad33b843e88270b0cefd7fc023ac251a
5
5
  SHA512:
6
- metadata.gz: 316e53bda0b475e1268c8272ef1f493f377034c2ef7020af71cb261b6c60548fb6d0a3f090001d9f591e77aa3d4c54845a76e23b851698537b7df1ffdf21bf71
7
- data.tar.gz: 7182e2ac76b85ecac8b3c49f812f53a2a1e936bdb61a4b6c2fe60d63e513e49a45004c041f12a215c1d920e93392653ac74f927b846719efdfa6a54e492e694b
6
+ metadata.gz: 865e638f07b0667a2bfe1eec952877489f75fa8e5dfdf891289fa559de9dae9dab421d71929962bca15c12525ecb8626a8daaa46223803302d455621db1d15c5
7
+ data.tar.gz: '08b0e605470738e1be904119b2e1db03fb1b413b386b3cc19ebfb53e0e710f2e7db23d7f0e276302d1a3b9b596ae85e99c3f891f68e3c650a75f489755882519'
data/README.md CHANGED
@@ -370,6 +370,56 @@ price = Superfeature::Price.new(99.999, amount_precision: 3, percent_precision:
370
370
  price.to_formatted_s # => "99.999"
371
371
  ```
372
372
 
373
+ ### Discount objects
374
+
375
+ For more control, use typed Discount objects directly:
376
+
377
+ ```ruby
378
+ # Fixed dollar discount
379
+ Superfeature::Discount::Fixed(20) # $20 off
380
+
381
+ # Percentage discount
382
+ Superfeature::Discount::Percent(25) # 25% off
383
+ Superfeature::Discount::Percent(10.5) # 10.5% off
384
+
385
+ # Bundle multiple discounts (note: splat args, not array)
386
+ Superfeature::Discount::Bundle(
387
+ Superfeature::Discount::Fixed(5),
388
+ Superfeature::Discount::Percent(20)
389
+ )
390
+ ```
391
+
392
+ Or with `include Superfeature`:
393
+
394
+ ```ruby
395
+ include Superfeature
396
+
397
+ Price(100).discount(Percent(25))
398
+ Price(100).discount(Fixed(20))
399
+ Price(100).discount(Bundle(Fixed(5), Percent(20)))
400
+ ```
401
+
402
+ ### Custom discount sources with `to_discount`
403
+
404
+ Any object can be passed to `Price#discount` if it implements `to_discount`:
405
+
406
+ ```ruby
407
+ class Promotion < ApplicationRecord
408
+ def to_discount
409
+ Superfeature::Discount::Percent.new(percent_off)
410
+ end
411
+ end
412
+
413
+ promo = Promotion.find(1)
414
+ price = Superfeature::Price.new(100).discount(promo)
415
+
416
+ price.amount # => discounted amount
417
+ price.discount_source # => the Promotion instance
418
+ price.discount_source.name # => access promotion metadata
419
+ ```
420
+
421
+ This allows rich domain objects (promotions, deals, coupons) to be used directly with Price while preserving access to their metadata via `discount_source`.
422
+
373
423
  ## Comparable libraries
374
424
 
375
425
  There's a few pretty great feature flag libraries that are worth mentioning so you can better evaluate what's right for you.
@@ -0,0 +1,44 @@
1
+ module Superfeature
2
+ module Discount
3
+ class Base
4
+ def to_discount = self
5
+ end
6
+
7
+ class Fixed < Base
8
+ attr_reader :amount
9
+
10
+ def initialize(amount)
11
+ @amount = amount
12
+ end
13
+
14
+ def apply(price) = price - amount
15
+ end
16
+
17
+ class Percent < Base
18
+ attr_reader :percent
19
+
20
+ def initialize(percent)
21
+ @percent = percent
22
+ end
23
+
24
+ def apply(price) = price * (1 - percent / 100.0)
25
+ end
26
+
27
+ class Bundle < Base
28
+ attr_reader :discounts
29
+
30
+ def initialize(*discounts)
31
+ @discounts = discounts.flatten
32
+ end
33
+
34
+ def apply(price)
35
+ discounts.reduce(price) { |amt, d| d.to_discount.apply(amt) }
36
+ end
37
+ end
38
+
39
+ # Convenience methods: Discount::Fixed(20) instead of Discount::Fixed.new(20)
40
+ def self.Fixed(amount) = Fixed.new(amount)
41
+ def self.Percent(percent) = Percent.new(percent)
42
+ def self.Bundle(...) = Bundle.new(...)
43
+ end
44
+ end
@@ -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: true))
42
+ def enable(flag = true, *, **)
43
+ feature(*, **, limit: Limit::Boolean.new(enabled: flag))
44
44
  end
45
45
 
46
46
  def disable(*, **)
@@ -11,38 +11,39 @@ module Superfeature
11
11
  DEFAULT_AMOUNT_PRECISION = 2
12
12
  DEFAULT_PERCENT_PRECISION = 4
13
13
 
14
- attr_reader :amount, :original, :amount_precision, :percent_precision
14
+ attr_reader :amount, :original, :discount_source, :amount_precision, :percent_precision
15
15
 
16
- def initialize(amount, original: nil, amount_precision: DEFAULT_AMOUNT_PRECISION, percent_precision: DEFAULT_PERCENT_PRECISION)
16
+ def initialize(amount, original: nil, discount_source: nil, amount_precision: DEFAULT_AMOUNT_PRECISION, percent_precision: DEFAULT_PERCENT_PRECISION)
17
17
  @amount = amount.to_f
18
18
  @original = original
19
+ @discount_source = discount_source
19
20
  @amount_precision = amount_precision
20
21
  @percent_precision = percent_precision
21
22
  end
22
23
 
23
- # Apply a discount by parsing the input:
24
- # - "25%" → 25% off
25
- # - "$20" → $20 off
26
- # - 20 $20 off (numeric = always dollars)
27
- def discount(value)
28
- case value
29
- # Matches: "25%", "10.5%", "100 %"
30
- when /\A(\d+(?:\.\d+)?)\s*%\z/
31
- discount_percent($1.to_f / 100)
32
- # Matches: "$20", "$ 20", "20", "19.99", "$19.99"
33
- when /\A\$?\s*(\d+(?:\.\d+)?)\z/
34
- discount_fixed($1.to_f)
35
- when Numeric
36
- discount_fixed(value)
37
- else
38
- raise ArgumentError, "Invalid discount format: #{value.inspect}"
39
- end
24
+ # Apply a discount from various sources:
25
+ # - String: "25%" → 25% off, "$20" → $20 off
26
+ # - Numeric: 20 → $20 off
27
+ # - Discount object: Discount::Percent.new(25) 25% off
28
+ # - Any object responding to to_discount
29
+ # - nil: no discount, returns self
30
+ def discount(source)
31
+ return self if source.nil?
32
+
33
+ discount_obj = coerce_discount(source)
34
+ new_amount = [discount_obj.apply(@amount), 0].max.round(@amount_precision)
35
+
36
+ Price.new(new_amount,
37
+ original: self,
38
+ discount_source: source,
39
+ amount_precision: @amount_precision,
40
+ percent_precision: @percent_precision
41
+ )
40
42
  end
41
43
 
42
44
  # Apply a fixed dollar discount
43
45
  def discount_fixed(amount)
44
- new_amount = ([@amount - amount.to_f, 0].max).round(@amount_precision)
45
- Price.new(new_amount, original: self, amount_precision: @amount_precision, percent_precision: @percent_precision)
46
+ discount(Discount::Fixed.new(amount.to_f))
46
47
  end
47
48
 
48
49
  # Set the price to a specific amount (calculates discount from current amount)
@@ -53,9 +54,7 @@ module Superfeature
53
54
 
54
55
  # Apply a percentage discount (decimal, e.g., 0.25 for 25%)
55
56
  def discount_percent(percent)
56
- discount_amount = @amount * percent.to_f
57
- new_amount = (@amount - discount_amount).round(@amount_precision)
58
- Price.new(new_amount, original: self, amount_precision: @amount_precision, percent_precision: @percent_precision)
57
+ discount(Discount::Percent.new(percent.to_f * 100))
59
58
  end
60
59
 
61
60
  # Dollars saved from original price
@@ -99,5 +98,26 @@ module Superfeature
99
98
  "#<Price #{to_formatted_s}>"
100
99
  end
101
100
  end
101
+
102
+ private
103
+
104
+ def coerce_discount(source)
105
+ case source
106
+ when String then parse_discount_string(source)
107
+ when Numeric then Discount::Fixed.new(source)
108
+ else source.to_discount
109
+ end
110
+ end
111
+
112
+ def parse_discount_string(str)
113
+ case str
114
+ when /\A(\d+(?:\.\d+)?)\s*%\z/
115
+ Discount::Percent.new($1.to_f)
116
+ when /\A\$?\s*(\d+(?:\.\d+)?)\z/
117
+ Discount::Fixed.new($1.to_f)
118
+ else
119
+ raise ArgumentError, "Invalid discount format: #{str.inspect}"
120
+ end
121
+ end
102
122
  end
103
123
  end
@@ -1,3 +1,3 @@
1
1
  module Superfeature
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.7"
3
3
  end
data/lib/superfeature.rb CHANGED
@@ -4,7 +4,15 @@ require "superfeature/limit"
4
4
  require "superfeature/feature"
5
5
  require "superfeature/plan"
6
6
  require "superfeature/plan/collection"
7
+ require "superfeature/discount"
7
8
  require "superfeature/price"
8
9
 
9
10
  module Superfeature
10
- end
11
+ # Convenience methods for creating Discount objects.
12
+ # Use Superfeature::Fixed(20) or after `include Superfeature`, just Fixed(20)
13
+ def Fixed(...) = Discount::Fixed.new(...)
14
+ def Percent(...) = Discount::Percent.new(...)
15
+ def Bundle(...) = Discount::Bundle.new(...)
16
+ module_function :Fixed, :Percent, :Bundle
17
+ public :Fixed, :Percent, :Bundle
18
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: superfeature
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-01-05 00:00:00.000000000 Z
10
+ date: 2026-01-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -53,6 +53,7 @@ files:
53
53
  - lib/generators/superfeature/plan/plan_generator.rb
54
54
  - lib/generators/superfeature/plan/templates/plan.rb.tt
55
55
  - lib/superfeature.rb
56
+ - lib/superfeature/discount.rb
56
57
  - lib/superfeature/engine.rb
57
58
  - lib/superfeature/feature.rb
58
59
  - lib/superfeature/limit.rb