belt-pay 0.0.1 → 0.0.2

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: 673206d18a5fad12255912de6c7833796e147e88e99ce32d70ba48ef727ff9e4
4
- data.tar.gz: d5b2171c33ef68447b501183287ab53d209621e859e40a16e2721d0af60a74a2
3
+ metadata.gz: 614581460579c56502eed359de29c1f36f746c3a8697b6ba3aa073266740649b
4
+ data.tar.gz: 00d84db5eb7ce1c47f23f666b8eab1511ace5745e2ad18e48e8c9753b2751503
5
5
  SHA512:
6
- metadata.gz: 475ef6bdadb87b2712145d0e69f3f74c58a7fa07f7a6d2f293e0a34aa19f6a340794feb1d74e76c7d8dd584a1576650c4572670a443b640f7336e2a4e1f16a6a
7
- data.tar.gz: e03f179b799302a256b741ff882defab8064909ecef72c25f5ec3043e1c26d76fe2ffde9ae7c1014a6ad76c5d1b25dfe385d9df7ec860404e3a74f6d3c73f87a
6
+ metadata.gz: 51d01a9a411c28d3e080352bb265553b8de72d24e0166f953248910926b55bd139aa9a7864c7b7ba504a9a8470a12ef013ce1b5153f441c653ef97e847f1e65f
7
+ data.tar.gz: 4b8e05e523ca9abc3400a3455dfa7fb3b1adc0bd409eef90ed8856878e8938719663d45001ecbb3434e42fa17da07160be33f257acb753c900f9704ac05a7779
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.2 — 2026-09-05
4
+
5
+ - **Plan DSL (convention over configuration).** Declare subscription plans, their
6
+ prices (per interval), limits, and feature flags once via `Belt::Pay.plans do ... end`,
7
+ then look them up by symbolic key everywhere. Inspired by how popular Ruby billing
8
+ gems keep plan definitions in code.
9
+ - `Belt::Pay::Plan` — a plan's marketing copy, per-interval Stripe prices, named
10
+ `limit`s (with `:unlimited`), and boolean `feature`s.
11
+ - `Belt::Pay::PlanRegistry` — `Belt::Pay.plans`, `Belt::Pay.plan(:key)`,
12
+ `Belt::Pay.plan_for_price(price_id)`.
13
+ - `Belt::Pay.subscribe` / `Billable#subscribe!` now accept a `plan:` key (resolving
14
+ the right Stripe price for the `interval:`) in addition to a raw `price_id:`.
15
+ - `Billable` gains `#plan`, `#on_plan?`, `#plan_allows?(feature)`,
16
+ `#within_limit?(name, usage)`, and a `pay_plan` attribute.
17
+
3
18
  ## 0.0.1 — 2026-09-01
4
19
 
5
20
  - Initial release
data/README.md CHANGED
@@ -19,6 +19,67 @@ bundle install
19
19
  belt generate pay
20
20
  ```
21
21
 
22
+ ## Defining Plans (Convention over Configuration)
23
+
24
+ Declare your plans once — their prices, limits, and feature flags live in code, not
25
+ scattered across config. Look them up by a symbolic key everywhere else.
26
+
27
+ ```ruby
28
+ # config/initializers/pay.rb (or wherever you boot)
29
+ Belt::Pay.plans do
30
+ plan :free do
31
+ name 'Free'
32
+ description 'For small teams getting started'
33
+ limit :projects, 1
34
+ limit :seats, 3
35
+ end
36
+
37
+ plan :pro do
38
+ name 'Pro'
39
+ description 'For growing teams'
40
+ featured # highlight this plan in your UI
41
+ price 49, interval: :month, stripe_price: ENV['STRIPE_PRO_MONTH']
42
+ price 490, interval: :year, stripe_price: ENV['STRIPE_PRO_YEAR']
43
+ limit :projects, :unlimited
44
+ limit :seats, :unlimited
45
+ feature :sso, :audit_logs
46
+ end
47
+ end
48
+ ```
49
+
50
+ Then use the plans:
51
+
52
+ ```ruby
53
+ # Subscribe by plan key — belt-pay resolves the Stripe price for the interval
54
+ customer.subscribe!(plan: :pro, interval: :year)
55
+
56
+ # Look plans up
57
+ Belt::Pay.plan(:pro).amount(interval: :month) # => 49.0
58
+ Belt::Pay.plans.paid # => [pro, ...]
59
+ Belt::Pay.plans.featured # => the :pro plan
60
+ Belt::Pay.plans.to_a # => [{ key:, name:, prices:, limits:, ... }] for your frontend
61
+
62
+ # Gate features and enforce limits
63
+ customer.on_plan?(:pro) # => true
64
+ customer.plan_allows?(:sso) # => true
65
+ customer.within_limit?(:projects, current_count) # => false once the ceiling is hit
66
+ ```
67
+
68
+ ### Plan API
69
+
70
+ | Method | Description |
71
+ |--------|-------------|
72
+ | `Belt::Pay.plans { ... }` | Declare plans (block) or read the registry |
73
+ | `Belt::Pay.plan(:key)` | Look up one plan (nil if undeclared) |
74
+ | `Belt::Pay.plan_for_price(id)` | Find the plan owning a Stripe price ID |
75
+ | `plan.amount(interval:)` | Price in whole currency units |
76
+ | `plan.amount_cents(interval:)` | Price in cents |
77
+ | `plan.stripe_price_id(interval:)` | Stripe price ID for an interval |
78
+ | `plan.limit(:name)` | Read a limit (Integer, `:unlimited`, or nil) |
79
+ | `plan.allows?(:name, usage)` | Is `usage` under the limit? |
80
+ | `plan.includes_feature?(:name)` | Does the plan unlock a feature? |
81
+ | `plan.to_h` | Serialize for API/frontend |
82
+
22
83
  ## What You Get
23
84
 
24
85
  ### From the gem (no generation needed)
@@ -21,7 +21,7 @@ module Belt
21
21
  extend ActiveSupport::Concern
22
22
 
23
23
  included do
24
- attr_accessor :pay_customer_id, :pay_subscription_id, :pay_payment_method_id
24
+ attr_accessor :pay_customer_id, :pay_subscription_id, :pay_payment_method_id, :pay_plan
25
25
  end
26
26
 
27
27
  # Ensure this customer has a provider customer account.
@@ -45,16 +45,60 @@ module Belt
45
45
  end
46
46
 
47
47
  # Subscribe to a plan.
48
- # @param price_id [String] Provider price ID
48
+ #
49
+ # Pass either a declared plan key (`plan:`) or a raw `price_id:`.
50
+ # When a plan key is given, the customer's `pay_plan` is recorded so
51
+ # limits/features can be checked later without a Stripe round-trip.
52
+ #
53
+ # @param plan [Symbol, String, nil] A declared plan key (see Belt::Pay.plans)
54
+ # @param price_id [String, nil] Provider price ID (overrides plan lookup)
55
+ # @param interval [Symbol] Billing interval to pick from the plan (default :month)
49
56
  # @param metadata [Hash] Additional metadata to store on the subscription
50
57
  # @return [Hash] { subscription_id:, status: }
51
- def subscribe!(price_id:, metadata: {})
52
- result = Belt::Pay.subscribe(self, price_id: price_id, metadata: metadata)
58
+ def subscribe!(plan: nil, price_id: nil, interval: :month, metadata: {})
59
+ result = Belt::Pay.subscribe(self, plan: plan, price_id: price_id,
60
+ interval: interval, metadata: metadata)
53
61
  self.pay_subscription_id = result[:subscription_id]
62
+ self.pay_plan = plan.to_s if plan
54
63
  save(validate: false)
55
64
  result
56
65
  end
57
66
 
67
+ # The declared plan this customer is currently on (or nil).
68
+ # @return [Belt::Pay::Plan, nil]
69
+ def plan
70
+ Belt::Pay.plan(pay_plan) if pay_plan
71
+ end
72
+
73
+ # Is this customer on a given plan?
74
+ # @param plan_key [Symbol, String]
75
+ # @return [Boolean]
76
+ def on_plan?(plan_key)
77
+ pay_plan.to_s == plan_key.to_s
78
+ end
79
+
80
+ # Does this customer's plan allow a named feature?
81
+ # @param feature_name [Symbol, String]
82
+ # @return [Boolean]
83
+ def plan_allows?(feature_name)
84
+ p = plan
85
+ return false unless p
86
+
87
+ p.includes_feature?(feature_name)
88
+ end
89
+
90
+ # Is the given usage under this customer's plan limit for `limit_name`?
91
+ # Returns true when there is no plan or the limit is unlimited/undeclared.
92
+ # @param limit_name [Symbol, String]
93
+ # @param usage [Integer] Current usage count
94
+ # @return [Boolean]
95
+ def within_limit?(limit_name, usage)
96
+ p = plan
97
+ return true unless p
98
+
99
+ p.allows?(limit_name, usage)
100
+ end
101
+
58
102
  # Check if customer has an active subscription.
59
103
  # @return [Boolean]
60
104
  def active_subscription?
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Pay
5
+ # A single subscription plan definition.
6
+ #
7
+ # Plans are declared once (convention over configuration) via `Belt::Pay.plans`
8
+ # and looked up by their symbolic key. A plan bundles the human-facing marketing
9
+ # copy (name, description, price), the Stripe price IDs for each billing interval,
10
+ # and a set of named limits that the rest of your app can gate features on.
11
+ #
12
+ # @example
13
+ # Belt::Pay.plans do
14
+ # plan :free do
15
+ # name 'Free'
16
+ # price 0
17
+ # limit :projects, 1
18
+ # limit :seats, 3
19
+ # end
20
+ #
21
+ # plan :pro do
22
+ # name 'Pro'
23
+ # description 'For growing teams'
24
+ # price 49, interval: :month, stripe_price: 'price_month_xxx'
25
+ # price 490, interval: :year, stripe_price: 'price_year_xxx'
26
+ # limit :projects, 25
27
+ # limit :seats, :unlimited
28
+ # feature :sso
29
+ # end
30
+ # end
31
+ #
32
+ class Plan
33
+ # Sentinel used for limits that have no ceiling.
34
+ UNLIMITED = :unlimited
35
+
36
+ attr_reader :key
37
+
38
+ def initialize(key)
39
+ @key = key.to_sym
40
+ @name = key.to_s.capitalize
41
+ @description = nil
42
+ @featured = false
43
+ @prices = {} # interval => { amount_cents:, stripe_price: }
44
+ @limits = {} # name => Integer | :unlimited
45
+ @features = [] # list of symbolic feature flags
46
+ @metadata = {}
47
+ end
48
+
49
+ # --- DSL setters (called inside `plan :key do ... end`) ---
50
+
51
+ # Human-facing plan name. Reader when called with no args.
52
+ def name(value = nil)
53
+ return @name if value.nil?
54
+
55
+ @name = value
56
+ end
57
+
58
+ # Marketing description. Reader when called with no args.
59
+ def description(value = nil)
60
+ return @description if value.nil?
61
+
62
+ @description = value
63
+ end
64
+
65
+ # Mark this plan as the "most popular" / highlighted plan.
66
+ def featured(value = true)
67
+ @featured = value
68
+ end
69
+
70
+ # Declare a price for a billing interval.
71
+ #
72
+ # @param amount [Numeric] Price in whole currency units (e.g. dollars). Stored as cents.
73
+ # @param interval [Symbol] :month | :year | :once (default :month)
74
+ # @param stripe_price [String, nil] The Stripe price ID backing this amount.
75
+ def price(amount, interval: :month, stripe_price: nil)
76
+ @prices[interval.to_sym] = {
77
+ amount_cents: (amount.to_f * 100).round,
78
+ stripe_price: stripe_price
79
+ }
80
+ end
81
+
82
+ # Declare a boolean feature this plan unlocks (e.g. :sso, :audit_logs).
83
+ def feature(*names)
84
+ @features.concat(names.map(&:to_sym))
85
+ end
86
+
87
+ # Arbitrary free-form metadata carried onto Stripe subscriptions.
88
+ def metadata(hash = nil)
89
+ return @metadata if hash.nil?
90
+
91
+ @metadata.merge!(hash)
92
+ end
93
+
94
+ # --- Query API (used by the rest of your app) ---
95
+
96
+ def featured?
97
+ @featured
98
+ end
99
+
100
+ # The Stripe price ID for a given interval (defaults to :month, falls back
101
+ # to the only price if the plan is single-interval).
102
+ def stripe_price_id(interval: :month)
103
+ entry = @prices[interval.to_sym] || @prices.values.first
104
+ entry && entry[:stripe_price]
105
+ end
106
+
107
+ # Price in cents for a given interval.
108
+ def amount_cents(interval: :month)
109
+ entry = @prices[interval.to_sym] || @prices.values.first
110
+ entry ? entry[:amount_cents] : 0
111
+ end
112
+
113
+ # Price in whole currency units (e.g. dollars) for a given interval.
114
+ def amount(interval: :month)
115
+ amount_cents(interval: interval) / 100.0
116
+ end
117
+
118
+ # Intervals this plan is priced for.
119
+ def intervals
120
+ @prices.keys
121
+ end
122
+
123
+ # Is this a free plan (no paid intervals)?
124
+ def free?
125
+ @prices.empty? || @prices.values.all? { |p| p[:amount_cents].zero? }
126
+ end
127
+
128
+ # Declare (2-arg form) or read (1-arg form) a named limit.
129
+ # Use `:unlimited` for no ceiling.
130
+ #
131
+ # @example declare
132
+ # limit :projects, 25
133
+ # limit :seats, :unlimited
134
+ # @example read
135
+ # plan.limit(:projects) # => 25
136
+ #
137
+ # @param name [Symbol] Limit name (e.g. :projects, :seats)
138
+ # @param value [Integer, Symbol] Ceiling, or :unlimited (setter form only)
139
+ # @return [Integer, Symbol, nil] The limit when reading; nil if undeclared.
140
+ def limit(name, *value)
141
+ if value.empty?
142
+ @limits[name.to_sym]
143
+ else
144
+ v = value.first
145
+ @limits[name.to_sym] = v == UNLIMITED ? UNLIMITED : Integer(v)
146
+ end
147
+ end
148
+
149
+ # Is a given usage count allowed under this plan's limit?
150
+ #
151
+ # @param name [Symbol] Limit name
152
+ # @param usage [Integer] Current usage count
153
+ # @return [Boolean] true if under (or at) the limit, or unlimited/undeclared
154
+ def allows?(name, usage)
155
+ ceiling = @limits[name.to_sym]
156
+ return true if ceiling.nil? || ceiling == UNLIMITED
157
+
158
+ usage < ceiling
159
+ end
160
+
161
+ # Does this plan include a boolean feature?
162
+ def includes_feature?(name)
163
+ @features.include?(name.to_sym)
164
+ end
165
+
166
+ def limits
167
+ @limits.dup
168
+ end
169
+
170
+ def features
171
+ @features.dup
172
+ end
173
+
174
+ # Serialize for API/frontend consumption.
175
+ def to_h
176
+ {
177
+ key: @key.to_s,
178
+ name: @name,
179
+ description: @description,
180
+ featured: @featured,
181
+ free: free?,
182
+ prices: @prices.transform_values { |p| p.dup },
183
+ limits: @limits.transform_values { |v| v == UNLIMITED ? 'unlimited' : v },
184
+ features: @features.map(&:to_s)
185
+ }
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'plan'
4
+
5
+ module Belt
6
+ module Pay
7
+ # Registry of declared subscription plans.
8
+ #
9
+ # Plans are declared once at boot (convention over configuration) and looked
10
+ # up by their symbolic key everywhere else. This mirrors how popular Ruby
11
+ # billing gems keep plan definitions in code rather than scattered config.
12
+ #
13
+ # @example Declaring plans
14
+ # Belt::Pay.plans do
15
+ # plan :free do
16
+ # name 'Free'
17
+ # limit :projects, 1
18
+ # end
19
+ #
20
+ # plan :pro do
21
+ # name 'Pro'
22
+ # featured
23
+ # price 49, interval: :month, stripe_price: ENV['STRIPE_PRO_MONTH']
24
+ # price 490, interval: :year, stripe_price: ENV['STRIPE_PRO_YEAR']
25
+ # limit :projects, :unlimited
26
+ # end
27
+ # end
28
+ #
29
+ # @example Looking plans up
30
+ # Belt::Pay.plan(:pro) # => #<Belt::Pay::Plan key=:pro>
31
+ # Belt::Pay.plans.all # => [free, pro, ...]
32
+ # Belt::Pay.plan_for_price('price_xxx') # => the plan owning that Stripe price
33
+ #
34
+ class PlanRegistry
35
+ def initialize
36
+ @plans = {}
37
+ end
38
+
39
+ # DSL entry: declare a plan by key with a config block.
40
+ def plan(key, &block)
41
+ p = @plans[key.to_sym] || Plan.new(key)
42
+ p.instance_eval(&block) if block
43
+ @plans[key.to_sym] = p
44
+ p
45
+ end
46
+
47
+ # Look up a plan by key. Returns nil if not declared.
48
+ def find(key)
49
+ return nil if key.nil?
50
+
51
+ @plans[key.to_sym]
52
+ end
53
+ alias [] find
54
+
55
+ # Look up a plan by key, raising if it isn't declared.
56
+ def find!(key)
57
+ find(key) || raise(Error, "Unknown plan: #{key.inspect}. " \
58
+ "Declared plans: #{keys.map(&:inspect).join(', ')}")
59
+ end
60
+
61
+ # Find the plan that owns a given Stripe price ID (across all intervals).
62
+ def find_by_stripe_price(price_id)
63
+ return nil if price_id.nil?
64
+
65
+ @plans.values.find do |pl|
66
+ pl.intervals.any? { |i| pl.stripe_price_id(interval: i) == price_id }
67
+ end
68
+ end
69
+
70
+ # All declared plans, in declaration order.
71
+ def all
72
+ @plans.values
73
+ end
74
+
75
+ # All declared plan keys.
76
+ def keys
77
+ @plans.keys
78
+ end
79
+
80
+ # Only the paid plans (have at least one non-zero price).
81
+ def paid
82
+ @plans.values.reject(&:free?)
83
+ end
84
+
85
+ # The single plan marked `featured` (or nil).
86
+ def featured
87
+ @plans.values.find(&:featured?)
88
+ end
89
+
90
+ def empty?
91
+ @plans.empty?
92
+ end
93
+
94
+ # Serialize every plan for API/frontend consumption.
95
+ def to_a
96
+ all.map(&:to_h)
97
+ end
98
+
99
+ # Wipe all plans (useful for tests).
100
+ def reset!
101
+ @plans = {}
102
+ end
103
+ end
104
+ end
105
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Belt
4
4
  module Pay
5
- VERSION = '0.0.1'
5
+ VERSION = '0.0.2'
6
6
  end
7
7
  end
data/lib/belt/pay.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'pay/version'
4
4
  require_relative 'pay/configuration'
5
+ require_relative 'pay/plan_registry'
5
6
  require_relative 'pay/transaction'
6
7
  require_relative 'pay/billable'
7
8
  require_relative 'pay/checkout'
@@ -33,6 +34,40 @@ module Belt
33
34
  @configuration = Configuration.new
34
35
  end
35
36
 
37
+ # --- Plans (convention-over-configuration subscription plan DSL) ---
38
+
39
+ # The plan registry. Pass a block to declare plans, or call with no block
40
+ # to read the registry.
41
+ #
42
+ # @example
43
+ # Belt::Pay.plans do
44
+ # plan(:pro) { name 'Pro'; price 49, stripe_price: 'price_xxx' }
45
+ # end
46
+ #
47
+ # @return [Belt::Pay::PlanRegistry]
48
+ def plans(&block)
49
+ @plans ||= PlanRegistry.new
50
+ @plans.instance_eval(&block) if block
51
+ @plans
52
+ end
53
+
54
+ # Look up a single plan by key. Returns nil if undeclared.
55
+ # @return [Belt::Pay::Plan, nil]
56
+ def plan(key)
57
+ plans.find(key)
58
+ end
59
+
60
+ # Find the declared plan backing a given Stripe price ID.
61
+ # @return [Belt::Pay::Plan, nil]
62
+ def plan_for_price(price_id)
63
+ plans.find_by_stripe_price(price_id)
64
+ end
65
+
66
+ # Reset the plan registry (useful for tests).
67
+ def reset_plans!
68
+ @plans = PlanRegistry.new
69
+ end
70
+
36
71
  # --- Convenience API (provider-agnostic names) ---
37
72
 
38
73
  # Ensure a payment provider customer exists for this user.
@@ -66,12 +101,21 @@ module Belt
66
101
  end
67
102
 
68
103
  # Subscribe a customer to a plan.
104
+ #
105
+ # Pass either a declared plan (`plan:`) — which resolves to the right Stripe
106
+ # price for the interval — or a raw provider `price_id:`.
107
+ #
69
108
  # @param customer [Object] Your app's user/customer model instance
70
- # @param price_id [String] Provider price/plan ID
109
+ # @param plan [Symbol, String, nil] A declared plan key (see Belt::Pay.plans)
110
+ # @param price_id [String, nil] Provider price/plan ID (overrides plan lookup)
111
+ # @param interval [Symbol] Billing interval to pick from the plan (default :month)
71
112
  # @param metadata [Hash] Additional metadata
72
113
  # @return [Hash] { subscription_id:, status: }
73
- def subscribe(customer, price_id:, metadata: {})
74
- Subscription.create(customer, price_id: price_id, metadata: metadata)
114
+ def subscribe(customer, plan: nil, price_id: nil, interval: :month, metadata: {})
115
+ price_id ||= resolve_plan_price(plan, interval)
116
+ meta = metadata.dup
117
+ meta[:plan] ||= plan.to_s if plan
118
+ Subscription.create(customer, price_id: price_id, metadata: meta)
75
119
  end
76
120
 
77
121
  # Cancel a customer's subscription.
@@ -111,6 +155,17 @@ module Belt
111
155
 
112
156
  private
113
157
 
158
+ # Resolve a plan key (+ interval) to its Stripe price ID.
159
+ def resolve_plan_price(plan_key, interval)
160
+ return nil if plan_key.nil?
161
+
162
+ pl = plans.find!(plan_key)
163
+ price_id = pl.stripe_price_id(interval: interval)
164
+ raise ConfigurationError, "Plan #{plan_key.inspect} has no Stripe price for interval #{interval.inspect}" unless price_id
165
+
166
+ price_id
167
+ end
168
+
114
169
  def resolve_provider
115
170
  case configuration.provider
116
171
  when :stripe
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt-pay
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -13,14 +13,14 @@ dependencies:
13
13
  name: belt
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '0.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.2'
26
26
  - !ruby/object:Gem::Dependency
@@ -59,6 +59,8 @@ files:
59
59
  - lib/belt/pay/controllers/webhooks_controller.rb
60
60
  - lib/belt/pay/customer_provisioner.rb
61
61
  - lib/belt/pay/payment_method_attacher.rb
62
+ - lib/belt/pay/plan.rb
63
+ - lib/belt/pay/plan_registry.rb
62
64
  - lib/belt/pay/providers/stripe.rb
63
65
  - lib/belt/pay/setup_intent_creator.rb
64
66
  - lib/belt/pay/subscription.rb