belt-pay 0.0.3 → 0.0.4

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: 6949c31f8f5adaec1e1a6c6d08e36bac5fb3cdac59fd2054a46be18869fa196c
4
- data.tar.gz: e07b2808dc053c34235ea4ec6ef183858c43401279a5f3d4b1c92ce7b46d2af1
3
+ metadata.gz: 225d94bd8d7f1b25849f237c4bb8432ce5b485b3a7ca5b88aee833d928481c4d
4
+ data.tar.gz: 7cabb1843e20857030506f0a3efe42a26aa375563f65c8ffb14334d84dc39b07
5
5
  SHA512:
6
- metadata.gz: 5ba551d781ba509f1aa8b93f47c84b8d22e6af8727659f7643ec73d6876b58811c7383fd8780c086a1c38dd8eefc3a546efc11297ab9ea2856be0c4a6fcb58ba
7
- data.tar.gz: 0e0b3dddaa5d235ba5d10362cc7d9d737ebfbc5f69649620b0adc5d42eb43d92a884560947a47cc53604f2080fd5179106eaab3015d527c6c274b9cd6f3f83b9
6
+ metadata.gz: ccf4f5972eabe4ffa0aa4c4d4be3207ac640970cea1ddaae857043bdf7d81a6df507932cc8c7704020326f578c46ee73ef0ea8e978b1443f761ca34f59deb960
7
+ data.tar.gz: 91f585c4b462664b2f193d8575fd5b551e969d613456ada465c4d3fa83387b4577b910fa64dd6e49f51b903082788ad8e48532a84174a76003368b5471ab40d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4 — 2026-10-20
4
+
5
+ - **`belt pay:setup` creates the Stripe webhook endpoint for you.** After the secret-key
6
+ prompt it reads `api_url` from `terraform output`, offers to create
7
+ `<api_url>/pay/webhooks` in Stripe (subscribed to every event Belt Pay handles), and
8
+ stores the returned `whsec_` signing secret directly. No Dashboard clicking.
9
+ - Shows whether the key is test or live mode before creating anything.
10
+ - If Stripe already has an endpoint for that URL, offers to replace it (the old one
11
+ is deleted only after the new secret is saved) or links to it in the Dashboard.
12
+ - Declining, or missing terraform output, prints step-by-step Dashboard instructions
13
+ with the exact URL and events, then prompts for the secret as before.
14
+ - `--webhook-url` / `BELT_PAY_WEBHOOK_URL` and `--webhook-events` /
15
+ `BELT_PAY_WEBHOOK_EVENTS` override the defaults.
16
+ - `Belt::Pay::WebhookHandler::EVENTS` lists the Stripe events the handler acts on.
17
+
3
18
  ## 0.0.3 — 2026-10-02
4
19
 
5
20
  - Add Belt-native `pay:setup`, `pay:show`, and `pay:edit` tasks through Belt's
data/README.md CHANGED
@@ -176,10 +176,17 @@ guided happy path uses `BELT_ENV` to select the environment:
176
176
  BELT_ENV=dev belt pay:setup
177
177
  ```
178
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.
179
+ It prompts without echo for the Stripe secret key, then offers to create the webhook
180
+ endpoint in Stripe for you: it reads `api_url` from `terraform output`, creates
181
+ `<api_url>/pay/webhooks` subscribed to every [handled event](#webhook-events), and stores
182
+ the `whsec_` signing secret Stripe returns. Everything goes directly through the Stripe
183
+ API and AWS SDK. Run it after `belt apply` so the URL is known.
184
+
185
+ Decline (or run it before the first apply) and it prints step-by-step Dashboard
186
+ instructions with the exact URL and events, then prompts for the signing secret. Leave
187
+ either prompt blank to preserve its existing value. Use `--webhook-url` /
188
+ `BELT_PAY_WEBHOOK_URL` or `--webhook-events` / `BELT_PAY_WEBHOOK_EVENTS` to override the
189
+ defaults. Test (`sk_test_`) and live (`sk_live_`) keys get separate endpoints and secrets.
183
190
 
184
191
  You can also pass the environment with standard Rake task-argument syntax:
185
192
 
@@ -354,7 +361,8 @@ module "pay" {
354
361
 
355
362
  ## Stripe Dashboard Setup
356
363
 
357
- After deploying, configure your Stripe webhook endpoint:
364
+ `BELT_ENV=<env> belt pay:setup` creates the webhook endpoint and stores its signing
365
+ secret for you. To do it by hand instead:
358
366
 
359
367
  1. Go to Stripe Dashboard → Developers → Webhooks
360
368
  2. Add endpoint: `https://<your-api-domain>/pay/webhooks`
@@ -315,20 +315,15 @@ module Belt
315
315
  # ...
316
316
  end
317
317
 
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.
323
-
324
- 4. Deploy:
318
+ 3. Deploy:
325
319
  belt apply <env>
326
320
 
327
- 5. Configure Stripe webhook endpoint:
328
- URL: https://<your-api-domain>/pay/webhooks
329
- Events: checkout.session.completed, checkout.session.expired,
330
- invoice.paid, invoice.payment_failed,
331
- customer.subscription.deleted, customer.subscription.updated
321
+ 4. Store your Stripe key and create the webhook endpoint:
322
+ BELT_ENV=<env> belt pay:setup
323
+
324
+ Prompts without echo for the Stripe secret key, then creates
325
+ <api_url>/pay/webhooks in Stripe and stores its signing secret.
326
+ Decline to get step-by-step Dashboard instructions instead.
332
327
 
333
328
  Quick Usage:
334
329
  # Create a checkout session
data/lib/belt/pay/cli.rb CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  require 'io/console'
4
4
  require 'json'
5
+ require 'open3'
5
6
  require 'optparse'
6
7
  require 'shellwords'
7
8
  require 'tempfile'
8
9
  require 'aws-sdk-secretsmanager'
10
+ require 'stripe'
11
+ require 'belt/pay/webhook_handler'
9
12
 
10
13
  module Belt
11
14
  module Pay
@@ -13,6 +16,7 @@ module Belt
13
16
  class Error < StandardError; end
14
17
 
15
18
  COMMANDS = %w[secrets:setup secrets:show secrets:edit].freeze
19
+ WEBHOOK_PATH = '/pay/webhooks'
16
20
 
17
21
  def self.start(args, input: $stdin, out: $stdout, err: $stderr)
18
22
  new(args, input: input, out: out, err: err).run
@@ -24,6 +28,9 @@ module Belt
24
28
  rescue Aws::Errors::ServiceError => e
25
29
  err.puts "AWS error: #{e.message}"
26
30
  1
31
+ rescue ::Stripe::StripeError => e
32
+ err.puts "Stripe error: #{e.message}"
33
+ 1
27
34
  end
28
35
 
29
36
  def initialize(args, input:, out:, err:)
@@ -78,6 +85,12 @@ module Belt
78
85
  opts.on('--region REGION', 'Override the AWS region') do |value|
79
86
  @options[:region] = value
80
87
  end
88
+ opts.on('--webhook-url URL', 'Override the Stripe webhook endpoint URL') do |value|
89
+ @options[:webhook_url] = value
90
+ end
91
+ opts.on('--webhook-events LIST', 'Comma-separated Stripe events for a new endpoint') do |value|
92
+ @options[:webhook_events] = value
93
+ end
81
94
  opts.on('-h', '--help', 'Show this help') do
82
95
  @out.puts help
83
96
  exit 0
@@ -108,11 +121,122 @@ module Belt
108
121
  updated = original.dup
109
122
 
110
123
  secret_key = hidden_prompt('Stripe secret key [blank keeps existing]')
111
- webhook_secret = hidden_prompt('Stripe webhook signing secret [blank keeps existing]')
112
124
  updated['stripe_secret_key'] = secret_key unless blank?(secret_key)
125
+
126
+ webhook_secret, replaced = webhook_secret_for(updated['stripe_secret_key'], original['stripe_webhook_secret'])
113
127
  updated['stripe_webhook_secret'] = webhook_secret unless blank?(webhook_secret)
114
128
 
115
129
  update_secret(response, original, updated)
130
+ delete_replaced_endpoints(updated['stripe_secret_key'], replaced)
131
+ end
132
+
133
+ # Returns [signing_secret, endpoints_to_delete]. Offers to create the Stripe
134
+ # webhook endpoint (Stripe only returns a signing secret at creation time);
135
+ # otherwise prints Dashboard instructions and prompts for the secret.
136
+ def webhook_secret_for(api_key, existing_secret)
137
+ if blank?(api_key)
138
+ @out.puts 'No Stripe secret key stored yet, so the webhook endpoint cannot be created for you.'
139
+ return [manual_webhook_secret(nil), []]
140
+ end
141
+
142
+ mode = stripe_mode(api_key)
143
+ url = webhook_url
144
+ @out.puts "Stripe account mode: #{mode}"
145
+ @out.puts "Webhook endpoint URL: #{url || '(unknown; run `belt apply` first or pass --webhook-url)'}"
146
+
147
+ create = !url.nil? && confirm("Create the #{mode}-mode webhook endpoint in Stripe now?", default: blank?(existing_secret))
148
+ return [manual_webhook_secret(url, mode: mode), []] unless create
149
+
150
+ existing = stripe_endpoints(api_key).select { |endpoint| endpoint.url == url }
151
+ if existing.any?
152
+ ids = existing.map(&:id).join(', ')
153
+ @out.puts "Stripe already has an endpoint for this URL (#{ids}). Stripe never re-sends a signing secret,"
154
+ @out.puts 'so setup can only replace it with a new endpoint (the old secret stops working).'
155
+ unless confirm('Replace it?', default: false)
156
+ @out.puts "Reveal its signing secret at #{dashboard_url(mode, existing.first.id)}"
157
+ return [hidden_prompt('Stripe webhook signing secret [blank keeps existing]'), []]
158
+ end
159
+ end
160
+
161
+ endpoint = ::Stripe::WebhookEndpoint.create(
162
+ { url: url, enabled_events: webhook_events, description: "Belt Pay (#{@app_name} #{@environment})" },
163
+ { api_key: api_key }
164
+ )
165
+ @out.puts "Created #{mode}-mode webhook endpoint #{endpoint.id} for #{webhook_events.size} events."
166
+ [endpoint.secret, existing.map(&:id)]
167
+ end
168
+
169
+ def manual_webhook_secret(url, mode: nil)
170
+ @out.puts <<~STEPS
171
+
172
+ To set up the webhook in the Stripe Dashboard#{" (#{mode} mode)" if mode}:
173
+ 1. Developers -> Webhooks -> Add destination (#{mode == 'live' ? 'https://dashboard.stripe.com/webhooks' : 'https://dashboard.stripe.com/test/webhooks'})
174
+ 2. Events from: Your account. API version: the default.
175
+ 3. Events: #{webhook_events.join(', ')}
176
+ 4. Destination type: Webhook endpoint
177
+ 5. URL: #{url || "your API base URL (`belt output #{@environment}` api_url) + #{WEBHOOK_PATH}"}
178
+ 6. Open the new endpoint -> Signing secret -> Reveal, and paste it below.
179
+ Each endpoint (and test vs. live mode) has its own whsec_ secret. Do not use the
180
+ one `stripe listen` prints; it only works for local forwarding.
181
+
182
+ STEPS
183
+ hidden_prompt('Stripe webhook signing secret [blank keeps existing]')
184
+ end
185
+
186
+ def delete_replaced_endpoints(api_key, ids)
187
+ ids.each do |id|
188
+ ::Stripe::WebhookEndpoint.delete(id, {}, { api_key: api_key })
189
+ @out.puts "Deleted replaced webhook endpoint #{id}"
190
+ end
191
+ end
192
+
193
+ def stripe_endpoints(api_key)
194
+ ::Stripe::WebhookEndpoint.list({ limit: 100 }, { api_key: api_key }).auto_paging_each.to_a
195
+ end
196
+
197
+ def webhook_url
198
+ return @webhook_url if defined?(@webhook_url)
199
+
200
+ explicit = @options[:webhook_url] || ENV['BELT_PAY_WEBHOOK_URL']
201
+ @webhook_url = if blank?(explicit)
202
+ api_url = terraform_output('api_url')
203
+ "#{api_url.chomp('/')}#{WEBHOOK_PATH}" unless blank?(api_url)
204
+ else
205
+ explicit.strip
206
+ end
207
+ end
208
+
209
+ def webhook_events
210
+ list = @options[:webhook_events] || ENV['BELT_PAY_WEBHOOK_EVENTS']
211
+ return Belt::Pay::WebhookHandler::EVENTS if blank?(list)
212
+
213
+ list.split(',').map(&:strip).reject(&:empty?)
214
+ end
215
+
216
+ def terraform_output(name)
217
+ env_dir = File.join(@root, 'infrastructure', @environment)
218
+ stdout, _stderr, status = Open3.capture3('terraform', "-chdir=#{env_dir}", 'output', '-raw', name)
219
+ stdout.strip if status.success?
220
+ rescue SystemCallError
221
+ nil
222
+ end
223
+
224
+ def stripe_mode(api_key)
225
+ api_key.match?(/\A[sr]k_live_/) ? 'live' : 'test'
226
+ end
227
+
228
+ def dashboard_url(mode, endpoint_id)
229
+ "https://dashboard.stripe.com/#{'test/' unless mode == 'live'}webhooks/#{endpoint_id}"
230
+ end
231
+
232
+ def confirm(question, default:)
233
+ @out.print "#{question} #{default ? '[Y/n]' : '[y/N]'} "
234
+ @out.flush
235
+ answer = @input.gets
236
+ @out.puts if answer.nil?
237
+ return default if answer.nil? || answer.strip.empty?
238
+
239
+ answer.strip.downcase.start_with?('y')
116
240
  end
117
241
 
118
242
  def show
@@ -171,7 +295,7 @@ module Belt
171
295
  @input.gets
172
296
  end
173
297
  @out.puts
174
- raise Error, 'input ended before both Stripe secrets were read' if value.nil?
298
+ raise Error, 'input ended before the Stripe secrets were read' if value.nil?
175
299
 
176
300
  value.chomp
177
301
  end
@@ -264,7 +388,9 @@ module Belt
264
388
  belt-pay secrets:edit ENV [options]
265
389
 
266
390
  Commands:
267
- secrets:setup Prompt without echo for Stripe's secret values
391
+ secrets:setup Prompt without echo for the Stripe secret key, then create
392
+ the webhook endpoint in Stripe and store its signing
393
+ secret (or walk through creating it in the Dashboard)
268
394
  secrets:show Print the decrypted JSON secret
269
395
  secrets:edit Edit the secret with $VISUAL or $EDITOR
270
396
 
@@ -273,12 +399,16 @@ module Belt
273
399
  --secret-name NAME Override <app>-<environment>-stripe
274
400
  --profile PROFILE Override the environment's AWS profile
275
401
  --region REGION Override the detected AWS region
402
+ --webhook-url URL Override <terraform api_url>#{WEBHOOK_PATH}
403
+ --webhook-events L Comma-separated events (default: every event Belt Pay handles)
276
404
  -h, --help Show this help
277
405
 
278
406
  Environment:
279
407
  BELT_ENV Environment when ENV is omitted
280
408
  AWS_PROFILE AWS profile (overrides infrastructure/ENV/belt.rb)
281
409
  AWS_REGION AWS region (default: us-east-1)
410
+ BELT_PAY_WEBHOOK_URL Same as --webhook-url
411
+ BELT_PAY_WEBHOOK_EVENTS Same as --webhook-events
282
412
  VISUAL, EDITOR Editor command for secrets:edit
283
413
 
284
414
  Examples:
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Belt
4
4
  module Pay
5
- VERSION = '0.0.3'
5
+ VERSION = '0.0.4'
6
6
  end
7
7
  end
@@ -5,6 +5,17 @@ module Belt
5
5
  # Handles incoming webhooks from the payment provider.
6
6
  # Verifies signatures, dispatches events, and records transactions.
7
7
  module WebhookHandler
8
+ # Stripe events #dispatch acts on. `belt pay:setup` subscribes new webhook
9
+ # endpoints to exactly this list.
10
+ EVENTS = %w[
11
+ checkout.session.completed
12
+ checkout.session.expired
13
+ invoice.paid
14
+ invoice.payment_failed
15
+ customer.subscription.deleted
16
+ customer.subscription.updated
17
+ ].freeze
18
+
8
19
  class << self
9
20
  # Process a raw webhook request.
10
21
  # @param payload [String] Raw request body
data/lib/tasks/pay.rake CHANGED
@@ -11,7 +11,7 @@ run_pay_secret_command = lambda do |command, environment|
11
11
  end
12
12
 
13
13
  namespace :pay do
14
- desc 'Prompt without echo for Stripe secrets (BELT_ENV or pay:setup[env])'
14
+ desc 'Store the Stripe key and create the webhook endpoint (BELT_ENV or pay:setup[env])'
15
15
  task :setup, [:environment] do |_task, args|
16
16
  run_pay_secret_command.call(:setup, args[:environment])
17
17
  end
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.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla