belt-pay 0.0.1 → 0.0.3

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: 6949c31f8f5adaec1e1a6c6d08e36bac5fb3cdac59fd2054a46be18869fa196c
4
+ data.tar.gz: e07b2808dc053c34235ea4ec6ef183858c43401279a5f3d4b1c92ce7b46d2af1
5
5
  SHA512:
6
- metadata.gz: 475ef6bdadb87b2712145d0e69f3f74c58a7fa07f7a6d2f293e0a34aa19f6a340794feb1d74e76c7d8dd584a1576650c4572670a443b640f7336e2a4e1f16a6a
7
- data.tar.gz: e03f179b799302a256b741ff882defab8064909ecef72c25f5ec3043e1c26d76fe2ffde9ae7c1014a6ad76c5d1b25dfe385d9df7ec860404e3a74f6d3c73f87a
6
+ metadata.gz: 5ba551d781ba509f1aa8b93f47c84b8d22e6af8727659f7643ec73d6876b58811c7383fd8780c086a1c38dd8eefc3a546efc11297ab9ea2856be0c4a6fcb58ba
7
+ data.tar.gz: 0e0b3dddaa5d235ba5d10362cc7d9d737ebfbc5f69649620b0adc5d42eb43d92a884560947a47cc53604f2080fd5179106eaab3015d527c6c274b9cd6f3f83b9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.3 — 2026-10-02
4
+
5
+ - Add Belt-native `pay:setup`, `pay:show`, and `pay:edit` tasks through Belt's
6
+ gem Rake-task extension mechanism. Select an environment with `BELT_ENV` or standard
7
+ task-argument syntax such as `belt 'pay:setup[dev]'`.
8
+ - Make `belt pay:setup` the happy path: it prompts without echo for Stripe's API and
9
+ webhook signing secrets, preserves either existing value when left blank, and writes
10
+ directly through the AWS SDK.
11
+ - Add `belt pay:show` and `belt pay:edit` for inspecting and securely editing each
12
+ environment's Stripe JSON secret in AWS Secrets Manager.
13
+ - Detect the Belt app name, AWS profile, and region from the selected environment,
14
+ with environment-variable and explicit overrides for custom setups.
15
+ - Retain the packaged `belt-pay secrets:*` executable as a backward-compatible alias,
16
+ pin the AWS Secrets Manager SDK, and include the pinned Rake runtime required for
17
+ task dispatch in fresh Belt apps.
18
+
19
+ ## 0.0.2 — 2026-09-05
20
+
21
+ - **Plan DSL (convention over configuration).** Declare subscription plans, their
22
+ prices (per interval), limits, and feature flags once via `Belt::Pay.plans do ... end`,
23
+ then look them up by symbolic key everywhere. Inspired by how popular Ruby billing
24
+ gems keep plan definitions in code.
25
+ - `Belt::Pay::Plan` — a plan's marketing copy, per-interval Stripe prices, named
26
+ `limit`s (with `:unlimited`), and boolean `feature`s.
27
+ - `Belt::Pay::PlanRegistry` — `Belt::Pay.plans`, `Belt::Pay.plan(:key)`,
28
+ `Belt::Pay.plan_for_price(price_id)`.
29
+ - `Belt::Pay.subscribe` / `Billable#subscribe!` now accept a `plan:` key (resolving
30
+ the right Stripe price for the `interval:`) in addition to a raw `price_id:`.
31
+ - `Billable` gains `#plan`, `#on_plan?`, `#plan_allows?(feature)`,
32
+ `#within_limit?(name, usage)`, and a `pay_plan` attribute.
33
+
3
34
  ## 0.0.1 — 2026-09-01
4
35
 
5
36
  - 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)
@@ -106,6 +167,54 @@ The Stripe secret should contain:
106
167
  }
107
168
  ```
108
169
 
170
+ ### Setting Up and Managing Secrets
171
+
172
+ Belt Pay hooks into Belt through its standard gem-provided Rake task mechanism. The
173
+ guided happy path uses `BELT_ENV` to select the environment:
174
+
175
+ ```bash
176
+ BELT_ENV=dev belt pay:setup
177
+ ```
178
+
179
+ It prompts without echo for the Stripe secret key and webhook signing secret, then
180
+ writes them directly through the AWS SDK. Leave either prompt blank to preserve its
181
+ existing value, so you can run setup once for the API key and again after Stripe gives
182
+ you the webhook signing secret.
183
+
184
+ You can also pass the environment with standard Rake task-argument syntax:
185
+
186
+ ```bash
187
+ belt 'pay:setup[dev]'
188
+ ```
189
+
190
+ Rails-like inspection and arbitrary JSON editing remain available for advanced use:
191
+
192
+ ```bash
193
+ BELT_ENV=dev belt pay:show
194
+ EDITOR="code --wait" BELT_ENV=dev belt pay:edit
195
+ # Or: belt 'pay:show[dev]' / belt 'pay:edit[dev]'
196
+ ```
197
+
198
+ `pay:show` prints the complete decrypted JSON to stdout. `pay:edit` opens it with
199
+ `$VISUAL` or `$EDITOR`, validates that the result is a JSON object, and writes a new
200
+ Secrets Manager version only when the content changed. Its temporary file is mode
201
+ `0600` and is removed when the editor closes. All write paths refuse to overwrite a
202
+ secret that changed while the command was open.
203
+
204
+ The tasks detect the application name from Terraform, the AWS profile from
205
+ `infrastructure/<env>/belt.rb`, and the region from Terraform or AWS environment
206
+ variables. Custom setups can override those values with `APP_NAME`,
207
+ `BELT_PAY_SECRET_NAME`, `AWS_PROFILE`, and `AWS_REGION`:
208
+
209
+ ```bash
210
+ APP_NAME=my_app BELT_ENV=prod AWS_PROFILE=my-prod-profile belt pay:setup
211
+ BELT_PAY_SECRET_NAME=custom-stripe-secret BELT_ENV=prod belt pay:edit
212
+ ```
213
+
214
+ Because `pay:show` intentionally reveals credentials, avoid redirecting its output to
215
+ logs or sharing it in chat. The packaged `belt-pay secrets:*` executable remains as a
216
+ backward-compatible alias, but the Belt-native tasks are the preferred interface.
217
+
109
218
  ## Common Patterns
110
219
 
111
220
  ### Annual Subscription (Feature Gating)
data/exe/belt-pay ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'belt/pay/cli'
5
+
6
+ exit Belt::Pay::CLI.start(ARGV)
@@ -315,9 +315,11 @@ module Belt
315
315
  # ...
316
316
  end
317
317
 
318
- 3. Create your Stripe API keys in Secrets Manager:
319
- Secret name: <app_name>-<env>-stripe
320
- Keys: stripe_secret_key, stripe_webhook_secret
318
+ 3. Add your Stripe API keys to Secrets Manager:
319
+ BELT_ENV=<env> belt pay:setup
320
+
321
+ Values are prompted without echo. Leave the webhook secret blank
322
+ until Stripe gives it to you, then run setup again.
321
323
 
322
324
  4. Deploy:
323
325
  belt apply <env>
@@ -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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'json'
5
+ require 'optparse'
6
+ require 'shellwords'
7
+ require 'tempfile'
8
+ require 'aws-sdk-secretsmanager'
9
+
10
+ module Belt
11
+ module Pay
12
+ class CLI
13
+ class Error < StandardError; end
14
+
15
+ COMMANDS = %w[secrets:setup secrets:show secrets:edit].freeze
16
+
17
+ def self.start(args, input: $stdin, out: $stdout, err: $stderr)
18
+ new(args, input: input, out: out, err: err).run
19
+ 0
20
+ rescue Error, OptionParser::ParseError => e
21
+ err.puts "Error: #{e.message}"
22
+ err.puts "Run `belt tasks --grep pay` for available Belt Pay commands."
23
+ 1
24
+ rescue Aws::Errors::ServiceError => e
25
+ err.puts "AWS error: #{e.message}"
26
+ 1
27
+ end
28
+
29
+ def initialize(args, input:, out:, err:)
30
+ @args = args.dup
31
+ @input = input
32
+ @out = out
33
+ @err = err
34
+ @options = {}
35
+ end
36
+
37
+ def run
38
+ command = @args.shift
39
+ if command.nil? || %w[help --help -h].include?(command)
40
+ @out.puts help
41
+ return
42
+ end
43
+
44
+ raise Error, "unknown command #{command.inspect}" unless COMMANDS.include?(command)
45
+
46
+ parse_options!
47
+ resolve_context!
48
+
49
+ case command
50
+ when 'secrets:setup' then setup
51
+ when 'secrets:show' then show
52
+ when 'secrets:edit' then edit
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def parse_options!
59
+ parser.parse!(@args)
60
+ @environment = @args.shift || ENV['BELT_ENV']
61
+ raise Error, 'environment is required (for example, dev or prod)' if blank?(@environment)
62
+ raise Error, "unexpected argument #{@args.first.inspect}" unless @args.empty?
63
+ end
64
+
65
+ def parser
66
+ OptionParser.new do |opts|
67
+ opts.banner = 'Usage: belt-pay secrets:setup|secrets:show|secrets:edit ENV [options]'
68
+
69
+ opts.on('--app-name NAME', 'Override the detected Belt application name') do |value|
70
+ @options[:app_name] = value
71
+ end
72
+ opts.on('--secret-name NAME', 'Override the Secrets Manager secret name') do |value|
73
+ @options[:secret_name] = value
74
+ end
75
+ opts.on('--profile PROFILE', 'Override the AWS profile') do |value|
76
+ @options[:profile] = value
77
+ end
78
+ opts.on('--region REGION', 'Override the AWS region') do |value|
79
+ @options[:region] = value
80
+ end
81
+ opts.on('-h', '--help', 'Show this help') do
82
+ @out.puts help
83
+ exit 0
84
+ end
85
+ end
86
+ end
87
+
88
+ def resolve_context!
89
+ @root = find_project_root
90
+ env_dir = File.join(@root, 'infrastructure', @environment)
91
+ raise Error, "environment not found: #{env_dir}" unless Dir.exist?(env_dir)
92
+
93
+ @app_name = @options[:app_name] || ENV['APP_NAME'] || detect_app_name(env_dir)
94
+ raise Error, 'could not determine the application name; pass --app-name or set APP_NAME' if blank?(@app_name)
95
+
96
+ @secret_name = @options[:secret_name] || ENV['BELT_PAY_SECRET_NAME'] ||
97
+ "#{@app_name}-#{@environment}-stripe"
98
+ @profile = @options[:profile] || ENV['AWS_PROFILE'] || detect_profile(env_dir)
99
+ @region = @options[:region] || ENV['AWS_REGION'] || ENV['AWS_DEFAULT_REGION'] || detect_region(env_dir)
100
+
101
+ ENV['AWS_PROFILE'] = @profile unless blank?(@profile)
102
+ @client = Aws::SecretsManager::Client.new(region: @region)
103
+ end
104
+
105
+ def setup
106
+ response = fetch_secret
107
+ original = parse_secret(response.secret_string)
108
+ updated = original.dup
109
+
110
+ secret_key = hidden_prompt('Stripe secret key [blank keeps existing]')
111
+ webhook_secret = hidden_prompt('Stripe webhook signing secret [blank keeps existing]')
112
+ updated['stripe_secret_key'] = secret_key unless blank?(secret_key)
113
+ updated['stripe_webhook_secret'] = webhook_secret unless blank?(webhook_secret)
114
+
115
+ update_secret(response, original, updated)
116
+ end
117
+
118
+ def show
119
+ response = fetch_secret
120
+ @out.puts JSON.pretty_generate(parse_secret(response.secret_string))
121
+ end
122
+
123
+ def edit
124
+ response = fetch_secret
125
+ original = parse_secret(response.secret_string)
126
+ edited = edit_json(original)
127
+
128
+ update_secret(response, original, edited)
129
+ end
130
+
131
+ def update_secret(response, original, updated)
132
+ if updated == original
133
+ @out.puts "Secret unchanged: #{@secret_name}"
134
+ return
135
+ end
136
+
137
+ latest = fetch_secret
138
+ if latest.version_id != response.version_id
139
+ raise Error, "#{@secret_name} changed while it was open; reopen it and apply your edits again"
140
+ end
141
+
142
+ @client.put_secret_value(
143
+ secret_id: @secret_name,
144
+ secret_string: JSON.generate(updated)
145
+ )
146
+ @out.puts "Updated secret: #{@secret_name}"
147
+ end
148
+
149
+ def fetch_secret
150
+ response = @client.get_secret_value(secret_id: @secret_name)
151
+ raise Error, "#{@secret_name} is binary; belt-pay only supports JSON string secrets" if response.secret_string.nil?
152
+
153
+ response
154
+ end
155
+
156
+ def parse_secret(value)
157
+ parsed = JSON.parse(value)
158
+ raise Error, "#{@secret_name} must contain a JSON object" unless parsed.is_a?(Hash)
159
+
160
+ parsed
161
+ rescue JSON::ParserError => e
162
+ raise Error, "#{@secret_name} does not contain valid JSON: #{e.message}"
163
+ end
164
+
165
+ def hidden_prompt(label)
166
+ @out.print "#{label}: "
167
+ @out.flush
168
+ value = if @input.respond_to?(:tty?) && @input.tty? && @input.respond_to?(:noecho)
169
+ @input.noecho { @input.gets }
170
+ else
171
+ @input.gets
172
+ end
173
+ @out.puts
174
+ raise Error, 'input ended before both Stripe secrets were read' if value.nil?
175
+
176
+ value.chomp
177
+ end
178
+
179
+ def edit_json(secret)
180
+ editor = ENV['VISUAL'] || ENV['EDITOR']
181
+ raise Error, 'set $VISUAL or $EDITOR before running secrets:edit' if blank?(editor)
182
+
183
+ Tempfile.create(["belt-pay-#{@environment}-", '.json']) do |file|
184
+ File.chmod(0o600, file.path)
185
+ file.write("#{JSON.pretty_generate(secret)}\n")
186
+ file.flush
187
+
188
+ success = system(*Shellwords.split(editor), file.path)
189
+ raise Error, 'editor exited unsuccessfully; secret was not updated' unless success
190
+
191
+ file.rewind
192
+ parse_secret(file.read)
193
+ end
194
+ end
195
+
196
+ def find_project_root
197
+ dir = File.expand_path(Dir.pwd)
198
+ loop do
199
+ routes = [
200
+ File.join(dir, 'config', 'routes.rb'),
201
+ File.join(dir, 'config', 'routes.tf.rb'),
202
+ File.join(dir, 'infrastructure', 'routes.tf.rb')
203
+ ]
204
+ return dir if routes.any? { |path| File.file?(path) }
205
+
206
+ parent = File.dirname(dir)
207
+ break if parent == dir
208
+
209
+ dir = parent
210
+ end
211
+
212
+ raise Error, 'could not find a Belt application; run this command inside one'
213
+ end
214
+
215
+ def detect_app_name(env_dir)
216
+ from_assignment(File.join(env_dir, 'terraform.tfvars'), 'app_name') ||
217
+ from_variable_default(File.join(env_dir, 'variables.tf'), 'app_name') ||
218
+ Dir.glob(File.join(@root, 'infrastructure', '*', 'terraform.tfvars')).sort.filter_map do |path|
219
+ from_assignment(path, 'app_name')
220
+ end.first ||
221
+ Dir.glob(File.join(@root, 'infrastructure', '*', 'variables.tf')).sort.filter_map do |path|
222
+ from_variable_default(path, 'app_name')
223
+ end.first ||
224
+ File.basename(@root)
225
+ end
226
+
227
+ def detect_profile(env_dir)
228
+ path = File.join(env_dir, 'belt.rb')
229
+ return unless File.file?(path)
230
+
231
+ File.read(path)[/config\.aws_profile\s*=\s*['\"]([^'\"]+)['\"]/, 1]
232
+ end
233
+
234
+ def detect_region(env_dir)
235
+ from_assignment(File.join(env_dir, 'terraform.tfvars'), 'aws_region') ||
236
+ from_variable_default(File.join(env_dir, 'variables.tf'), 'aws_region') ||
237
+ 'us-east-1'
238
+ end
239
+
240
+ def from_assignment(path, key)
241
+ return unless File.file?(path)
242
+
243
+ File.read(path)[/^\s*#{Regexp.escape(key)}\s*=\s*['\"]([^'\"]+)['\"]/, 1]
244
+ end
245
+
246
+ def from_variable_default(path, key)
247
+ return unless File.file?(path)
248
+
249
+ block = File.read(path)[/variable\s+['\"]#{Regexp.escape(key)}['\"]\s*\{(.*?)\}/m, 1]
250
+ block&.match(/^\s*default\s*=\s*['\"]([^'\"]+)['\"]/)&.captures&.first
251
+ end
252
+
253
+ def blank?(value)
254
+ value.nil? || value.strip.empty?
255
+ end
256
+
257
+ def help
258
+ <<~HELP
259
+ Manage a Belt application's Stripe secret in AWS Secrets Manager.
260
+
261
+ Usage:
262
+ belt-pay secrets:setup ENV [options]
263
+ belt-pay secrets:show ENV [options]
264
+ belt-pay secrets:edit ENV [options]
265
+
266
+ Commands:
267
+ secrets:setup Prompt without echo for Stripe's secret values
268
+ secrets:show Print the decrypted JSON secret
269
+ secrets:edit Edit the secret with $VISUAL or $EDITOR
270
+
271
+ Options:
272
+ --app-name NAME Override the detected application name
273
+ --secret-name NAME Override <app>-<environment>-stripe
274
+ --profile PROFILE Override the environment's AWS profile
275
+ --region REGION Override the detected AWS region
276
+ -h, --help Show this help
277
+
278
+ Environment:
279
+ BELT_ENV Environment when ENV is omitted
280
+ AWS_PROFILE AWS profile (overrides infrastructure/ENV/belt.rb)
281
+ AWS_REGION AWS region (default: us-east-1)
282
+ VISUAL, EDITOR Editor command for secrets:edit
283
+
284
+ Examples:
285
+ belt-pay secrets:setup dev
286
+ belt-pay secrets:show dev
287
+ belt-pay secrets:edit prod --profile my-prod-profile
288
+ BELT_ENV=staging belt-pay secrets:setup
289
+
290
+ Security:
291
+ secrets:setup reads values without echo and sends them directly through the
292
+ AWS SDK. secrets:show writes the complete secret to stdout. secrets:edit uses
293
+ a mode-0600 temporary file, validates JSON, and removes the file afterward.
294
+ HELP
295
+ end
296
+ end
297
+ end
298
+ end
@@ -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.3'
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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'belt/pay/cli'
4
+
5
+ run_pay_secret_command = lambda do |command, environment|
6
+ args = ["secrets:#{command}"]
7
+ args << environment unless environment.nil? || environment.to_s.strip.empty?
8
+
9
+ status = Belt::Pay::CLI.start(args)
10
+ exit status unless status.zero?
11
+ end
12
+
13
+ namespace :pay do
14
+ desc 'Prompt without echo for Stripe secrets (BELT_ENV or pay:setup[env])'
15
+ task :setup, [:environment] do |_task, args|
16
+ run_pay_secret_command.call(:setup, args[:environment])
17
+ end
18
+
19
+ desc 'Print the decrypted Stripe secret (BELT_ENV or pay:show[env])'
20
+ task :show, [:environment] do |_task, args|
21
+ run_pay_secret_command.call(:show, args[:environment])
22
+ end
23
+
24
+ desc 'Edit the Stripe secret as JSON (BELT_ENV or pay:edit[env])'
25
+ task :edit, [:environment] do |_task, args|
26
+ run_pay_secret_command.call(:edit, args[:environment])
27
+ end
28
+ end
metadata CHANGED
@@ -1,28 +1,56 @@
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.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: aws-sdk-secretsmanager
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - '='
17
+ - !ruby/object:Gem::Version
18
+ version: 1.136.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - '='
24
+ - !ruby/object:Gem::Version
25
+ version: 1.136.0
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: belt
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
- - - "~>"
30
+ - - ">="
17
31
  - !ruby/object:Gem::Version
18
32
  version: '0.2'
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
- - - "~>"
37
+ - - ">="
24
38
  - !ruby/object:Gem::Version
25
39
  version: '0.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - '='
45
+ - !ruby/object:Gem::Version
46
+ version: 13.4.2
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 13.4.2
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: stripe
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -43,22 +71,27 @@ description: Belt plugin providing payment collection, subscriptions, and billin
43
71
  email:
44
72
  - andy@stowzilla.com
45
73
  - adam@stowzilla.com
46
- executables: []
74
+ executables:
75
+ - belt-pay
47
76
  extensions: []
48
77
  extra_rdoc_files: []
49
78
  files:
50
79
  - CHANGELOG.md
51
80
  - LICENSE
52
81
  - README.md
82
+ - exe/belt-pay
53
83
  - lib/belt-pay.rb
54
84
  - lib/belt/generators/pay_generator.rb
55
85
  - lib/belt/pay.rb
56
86
  - lib/belt/pay/billable.rb
57
87
  - lib/belt/pay/checkout.rb
88
+ - lib/belt/pay/cli.rb
58
89
  - lib/belt/pay/configuration.rb
59
90
  - lib/belt/pay/controllers/webhooks_controller.rb
60
91
  - lib/belt/pay/customer_provisioner.rb
61
92
  - lib/belt/pay/payment_method_attacher.rb
93
+ - lib/belt/pay/plan.rb
94
+ - lib/belt/pay/plan_registry.rb
62
95
  - lib/belt/pay/providers/stripe.rb
63
96
  - lib/belt/pay/setup_intent_creator.rb
64
97
  - lib/belt/pay/subscription.rb
@@ -71,6 +104,7 @@ files:
71
104
  - lib/belt/pay/transaction.rb
72
105
  - lib/belt/pay/version.rb
73
106
  - lib/belt/pay/webhook_handler.rb
107
+ - lib/tasks/pay.rake
74
108
  homepage: https://github.com/stowzilla/belt-pay
75
109
  licenses:
76
110
  - MIT