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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +109 -0
- data/exe/belt-pay +6 -0
- data/lib/belt/generators/pay_generator.rb +5 -3
- data/lib/belt/pay/billable.rb +48 -4
- data/lib/belt/pay/cli.rb +298 -0
- data/lib/belt/pay/plan.rb +189 -0
- data/lib/belt/pay/plan_registry.rb +105 -0
- data/lib/belt/pay/version.rb +1 -1
- data/lib/belt/pay.rb +58 -3
- data/lib/tasks/pay.rake +28 -0
- metadata +39 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6949c31f8f5adaec1e1a6c6d08e36bac5fb3cdac59fd2054a46be18869fa196c
|
|
4
|
+
data.tar.gz: e07b2808dc053c34235ea4ec6ef183858c43401279a5f3d4b1c92ce7b46d2af1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -315,9 +315,11 @@ module Belt
|
|
|
315
315
|
# ...
|
|
316
316
|
end
|
|
317
317
|
|
|
318
|
-
3.
|
|
319
|
-
|
|
320
|
-
|
|
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>
|
data/lib/belt/pay/billable.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
52
|
-
result = Belt::Pay.subscribe(self,
|
|
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?
|
data/lib/belt/pay/cli.rb
ADDED
|
@@ -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
|
data/lib/belt/pay/version.rb
CHANGED
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
|
|
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
|
|
74
|
-
|
|
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
|
data/lib/tasks/pay.rake
ADDED
|
@@ -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.
|
|
4
|
+
version: 0.0.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
8
|
-
bindir:
|
|
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
|