belt-pay 0.0.1
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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +21 -0
- data/README.md +271 -0
- data/lib/belt/generators/pay_generator.rb +352 -0
- data/lib/belt/pay/billable.rb +94 -0
- data/lib/belt/pay/checkout.rb +50 -0
- data/lib/belt/pay/configuration.rb +23 -0
- data/lib/belt/pay/controllers/webhooks_controller.rb +45 -0
- data/lib/belt/pay/customer_provisioner.rb +41 -0
- data/lib/belt/pay/payment_method_attacher.rb +38 -0
- data/lib/belt/pay/providers/stripe.rb +211 -0
- data/lib/belt/pay/setup_intent_creator.rb +30 -0
- data/lib/belt/pay/subscription.rb +63 -0
- data/lib/belt/pay/templates/config/pay_webhooks.yml.erb +33 -0
- data/lib/belt/pay/templates/controllers/pay_webhooks_controller.rb.erb +26 -0
- data/lib/belt/pay/templates/lambda/pay_webhooks.rb.erb +21 -0
- data/lib/belt/pay/templates/terraform/main.tf.erb +73 -0
- data/lib/belt/pay/templates/terraform/outputs.tf.erb +14 -0
- data/lib/belt/pay/templates/terraform/variables.tf.erb +23 -0
- data/lib/belt/pay/transaction.rb +103 -0
- data/lib/belt/pay/version.rb +7 -0
- data/lib/belt/pay/webhook_handler.rb +153 -0
- data/lib/belt/pay.rb +124 -0
- data/lib/belt-pay.rb +3 -0
- metadata +99 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module Pay
|
|
7
|
+
# Mix into your Customer/User model to get payment capabilities.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# class Customer < ActiveItem::Base
|
|
11
|
+
# include Belt::Pay::Billable
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# customer.ensure_pay_customer!
|
|
15
|
+
# customer.subscribe!(price_id: 'price_xxx')
|
|
16
|
+
# customer.active_subscription?
|
|
17
|
+
# customer.cancel_subscription!
|
|
18
|
+
# customer.transactions
|
|
19
|
+
#
|
|
20
|
+
module Billable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
attr_accessor :pay_customer_id, :pay_subscription_id, :pay_payment_method_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Ensure this customer has a provider customer account.
|
|
28
|
+
# Creates one if it doesn't exist. Idempotent.
|
|
29
|
+
# @return [String] The provider customer ID
|
|
30
|
+
def ensure_pay_customer!
|
|
31
|
+
Belt::Pay.ensure_customer(self)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Attach a payment method and set as default.
|
|
35
|
+
# @param payment_method_id [String] Provider payment method token (e.g., 'pm_xxx')
|
|
36
|
+
# @return [PaymentMethodAttacher::Result]
|
|
37
|
+
def attach_payment_method(payment_method_id)
|
|
38
|
+
Belt::Pay.attach_payment_method(self, payment_method_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create a setup intent for collecting payment details.
|
|
42
|
+
# @return [SetupIntentCreator::Result] { client_secret:, pay_customer_id: }
|
|
43
|
+
def create_setup_intent
|
|
44
|
+
Belt::Pay.create_setup_intent(self)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Subscribe to a plan.
|
|
48
|
+
# @param price_id [String] Provider price ID
|
|
49
|
+
# @param metadata [Hash] Additional metadata to store on the subscription
|
|
50
|
+
# @return [Hash] { subscription_id:, status: }
|
|
51
|
+
def subscribe!(price_id:, metadata: {})
|
|
52
|
+
result = Belt::Pay.subscribe(self, price_id: price_id, metadata: metadata)
|
|
53
|
+
self.pay_subscription_id = result[:subscription_id]
|
|
54
|
+
save(validate: false)
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if customer has an active subscription.
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def active_subscription?
|
|
61
|
+
return false unless pay_subscription_id
|
|
62
|
+
|
|
63
|
+
Belt::Pay::Subscription.active?(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Cancel the current subscription.
|
|
67
|
+
# @param immediately [Boolean] Cancel now (true) or at period end (false, default)
|
|
68
|
+
def cancel_subscription!(immediately: false)
|
|
69
|
+
Belt::Pay.cancel_subscription(self, immediately: immediately)
|
|
70
|
+
self.pay_subscription_id = nil if immediately
|
|
71
|
+
save(validate: false) if immediately
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Generate a billing portal URL for self-service management.
|
|
75
|
+
# @param return_url [String] URL to redirect back to
|
|
76
|
+
# @return [Hash] { url: }
|
|
77
|
+
def billing_portal_url(return_url:)
|
|
78
|
+
Belt::Pay.billing_portal(self, return_url: return_url)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get payment method details (last4, brand, expiry).
|
|
82
|
+
# @return [Hash, nil] { last4:, brand:, exp_month:, exp_year: }
|
|
83
|
+
def payment_method_details
|
|
84
|
+
Belt::Pay.provider.payment_method_details(self)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get all transactions for this customer.
|
|
88
|
+
# @return [Array<Belt::Pay::Transaction>]
|
|
89
|
+
def transactions
|
|
90
|
+
Belt::Pay::Transaction.for_customer(id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
# Creates checkout sessions for one-time or subscription payments.
|
|
6
|
+
module Checkout
|
|
7
|
+
class << self
|
|
8
|
+
# Create a checkout session.
|
|
9
|
+
# @param customer [Object] App model
|
|
10
|
+
# @param line_items [Array<Hash>] Items to charge for
|
|
11
|
+
# @param mode [String] 'payment' or 'subscription'
|
|
12
|
+
# @param success_url [String]
|
|
13
|
+
# @param cancel_url [String]
|
|
14
|
+
# @param metadata [Hash]
|
|
15
|
+
# @return [Hash] { url:, session_id: }
|
|
16
|
+
def create(customer, line_items:, mode: 'payment', success_url:, cancel_url:, metadata: {}, **options)
|
|
17
|
+
# Ensure provider customer exists
|
|
18
|
+
CustomerProvisioner.new(customer).call
|
|
19
|
+
|
|
20
|
+
result = Belt::Pay.provider.create_checkout_session(
|
|
21
|
+
customer.pay_customer_id,
|
|
22
|
+
line_items: line_items,
|
|
23
|
+
mode: mode,
|
|
24
|
+
success_url: success_url,
|
|
25
|
+
cancel_url: cancel_url,
|
|
26
|
+
metadata: metadata.merge(app_customer_id: customer.id),
|
|
27
|
+
**options
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Record pending transaction
|
|
31
|
+
Transaction.create!(
|
|
32
|
+
customer_id: customer.id,
|
|
33
|
+
provider: Belt::Pay.configuration.provider.to_s,
|
|
34
|
+
provider_session_id: result[:session_id],
|
|
35
|
+
type: mode == 'subscription' ? 'subscription' : 'checkout',
|
|
36
|
+
status: 'pending',
|
|
37
|
+
currency: 'usd',
|
|
38
|
+
description: "Checkout session created (#{mode})",
|
|
39
|
+
metadata: metadata
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
Belt::Pay.log(:info, 'Belt::Pay::Checkout: session created',
|
|
43
|
+
customer_id: customer.id, session_id: result[:session_id], mode: mode)
|
|
44
|
+
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_accessor :provider, :secret_name, :webhook_secret_name,
|
|
7
|
+
:table_name_prefix, :logger, :test_mode
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@provider = :stripe
|
|
11
|
+
@secret_name = ENV['BELT_PAY_SECRET_NAME']
|
|
12
|
+
@webhook_secret_name = ENV['BELT_PAY_WEBHOOK_SECRET_NAME']
|
|
13
|
+
@table_name_prefix = "#{ENV['APP_NAME']}-#{ENV['ENVIRONMENT']}"
|
|
14
|
+
@logger = nil
|
|
15
|
+
@test_mode = ENV['BELT_PAY_MODE'] != 'live'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def transactions_table_name
|
|
19
|
+
"#{table_name_prefix}-pay-transactions"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
module Controllers
|
|
6
|
+
# Default webhook controller for Stripe events.
|
|
7
|
+
# Lives in the gem — override by generating a controller into your app:
|
|
8
|
+
# belt g pay --controllers
|
|
9
|
+
class WebhooksController < BeltController::Base
|
|
10
|
+
skip_before_action :authenticate!, only: [:webhook]
|
|
11
|
+
|
|
12
|
+
# POST /pay/webhooks
|
|
13
|
+
def webhook
|
|
14
|
+
payload = raw_body
|
|
15
|
+
signature = headers['Stripe-Signature'] || headers['stripe-signature']
|
|
16
|
+
|
|
17
|
+
unless signature
|
|
18
|
+
return error_response('Missing Stripe-Signature header', 400)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result = Belt::Pay::WebhookHandler.process(payload: payload, signature: signature)
|
|
22
|
+
success_response(result)
|
|
23
|
+
rescue ::Stripe::SignatureVerificationError => e
|
|
24
|
+
Belt::Pay.log(:warn, 'Belt::Pay webhook signature verification failed', error: e.message)
|
|
25
|
+
error_response('Invalid signature', 400)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Belt::Pay.log(:error, 'Belt::Pay webhook processing error', error: e.message)
|
|
28
|
+
error_response('Webhook processing error', 500)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Get the raw request body for signature verification.
|
|
34
|
+
# Stripe requires the exact raw body — not parsed JSON.
|
|
35
|
+
def raw_body
|
|
36
|
+
event.dig('body') || ''
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def headers
|
|
40
|
+
event.dig('headers') || {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
# Ensures a payment provider customer exists for a given app customer.
|
|
6
|
+
# Idempotent — safe to call multiple times.
|
|
7
|
+
class CustomerProvisioner
|
|
8
|
+
attr_reader :customer
|
|
9
|
+
|
|
10
|
+
def initialize(customer)
|
|
11
|
+
@customer = customer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String] The provider customer ID
|
|
15
|
+
def call
|
|
16
|
+
return customer.pay_customer_id if customer.pay_customer_id
|
|
17
|
+
|
|
18
|
+
# Check for concurrent write (another request may have created one)
|
|
19
|
+
fresh = customer.class.find(customer.id)
|
|
20
|
+
if fresh&.pay_customer_id
|
|
21
|
+
customer.pay_customer_id = fresh.pay_customer_id
|
|
22
|
+
Belt::Pay.log(:info, 'Belt::Pay::CustomerProvisioner: found existing (concurrent write)',
|
|
23
|
+
customer_id: customer.id, pay_customer_id: fresh.pay_customer_id)
|
|
24
|
+
return fresh.pay_customer_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create via provider
|
|
28
|
+
pay_customer_id = Belt::Pay.provider.create_customer(customer)
|
|
29
|
+
|
|
30
|
+
customer.pay_customer_id = pay_customer_id
|
|
31
|
+
unless customer.save(validate: false)
|
|
32
|
+
raise Error, "Belt::Pay::CustomerProvisioner: failed to persist pay_customer_id=#{pay_customer_id}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Belt::Pay.log(:info, 'Belt::Pay::CustomerProvisioner: created',
|
|
36
|
+
customer_id: customer.id, pay_customer_id: pay_customer_id)
|
|
37
|
+
pay_customer_id
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
# Attaches a payment method to a customer's provider account and sets it as default.
|
|
6
|
+
class PaymentMethodAttacher
|
|
7
|
+
Result = Struct.new(:pay_customer_id, :payment_method_id, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :customer, :payment_method_id
|
|
10
|
+
|
|
11
|
+
def initialize(customer, payment_method_id)
|
|
12
|
+
@customer = customer
|
|
13
|
+
@payment_method_id = payment_method_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Result]
|
|
17
|
+
def call
|
|
18
|
+
# Ensure provider customer exists
|
|
19
|
+
CustomerProvisioner.new(customer).call
|
|
20
|
+
|
|
21
|
+
# Attach via provider
|
|
22
|
+
Belt::Pay.provider.attach_payment_method(customer.pay_customer_id, payment_method_id)
|
|
23
|
+
|
|
24
|
+
# Persist the payment method ID
|
|
25
|
+
customer.pay_payment_method_id = payment_method_id
|
|
26
|
+
unless customer.save(validate: false)
|
|
27
|
+
raise Error, "Belt::Pay::PaymentMethodAttacher: failed to persist payment_method_id=#{payment_method_id}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Belt::Pay.log(:info, 'Belt::Pay::PaymentMethodAttacher: completed',
|
|
31
|
+
customer_id: customer.id, pay_customer_id: customer.pay_customer_id,
|
|
32
|
+
payment_method_id: payment_method_id)
|
|
33
|
+
|
|
34
|
+
Result.new(pay_customer_id: customer.pay_customer_id, payment_method_id: payment_method_id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stripe'
|
|
4
|
+
|
|
5
|
+
module Belt
|
|
6
|
+
module Pay
|
|
7
|
+
module Providers
|
|
8
|
+
# Stripe provider adapter.
|
|
9
|
+
# Handles API key management and provides the Stripe-specific implementations
|
|
10
|
+
# that the generic Belt::Pay API delegates to.
|
|
11
|
+
class Stripe
|
|
12
|
+
# Ensure the Stripe API key is set for the current request.
|
|
13
|
+
# Reads from Secrets Manager via the configured secret name.
|
|
14
|
+
def ensure_api_key!
|
|
15
|
+
::Stripe.api_key ||= fetch_api_key
|
|
16
|
+
raise ConfigurationError, 'Stripe API key not configured' unless ::Stripe.api_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get the webhook signing secret for signature verification.
|
|
20
|
+
# @return [String]
|
|
21
|
+
def webhook_signing_secret
|
|
22
|
+
@webhook_signing_secret ||= fetch_webhook_secret
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create a Stripe customer for the given user.
|
|
26
|
+
# @param customer [Object] App model with #id, #email
|
|
27
|
+
# @return [String] Stripe customer ID (cus_xxx)
|
|
28
|
+
def create_customer(customer)
|
|
29
|
+
ensure_api_key!
|
|
30
|
+
stripe_customer = ::Stripe::Customer.create(
|
|
31
|
+
email: customer.respond_to?(:email) ? customer.email : nil,
|
|
32
|
+
metadata: { app_customer_id: customer.id }
|
|
33
|
+
)
|
|
34
|
+
stripe_customer.id
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Attach a payment method to a Stripe customer and set as default.
|
|
38
|
+
# @param pay_customer_id [String] Stripe customer ID
|
|
39
|
+
# @param payment_method_id [String] Payment method ID (pm_xxx)
|
|
40
|
+
def attach_payment_method(pay_customer_id, payment_method_id)
|
|
41
|
+
ensure_api_key!
|
|
42
|
+
::Stripe::PaymentMethod.attach(payment_method_id, { customer: pay_customer_id })
|
|
43
|
+
::Stripe::Customer.update(pay_customer_id, {
|
|
44
|
+
invoice_settings: { default_payment_method: payment_method_id }
|
|
45
|
+
})
|
|
46
|
+
rescue ::Stripe::InvalidRequestError => e
|
|
47
|
+
raise unless e.message.include?('already been attached')
|
|
48
|
+
|
|
49
|
+
# Idempotent — already attached, just set as default
|
|
50
|
+
::Stripe::Customer.update(pay_customer_id, {
|
|
51
|
+
invoice_settings: { default_payment_method: payment_method_id }
|
|
52
|
+
})
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Create a SetupIntent for collecting payment details.
|
|
56
|
+
# @param pay_customer_id [String] Stripe customer ID
|
|
57
|
+
# @param customer_id [String] App customer ID (for metadata)
|
|
58
|
+
# @return [Hash] { client_secret:, setup_intent_id: }
|
|
59
|
+
def create_setup_intent(pay_customer_id, customer_id:)
|
|
60
|
+
ensure_api_key!
|
|
61
|
+
setup_intent = ::Stripe::SetupIntent.create({
|
|
62
|
+
customer: pay_customer_id,
|
|
63
|
+
payment_method_types: ['card'],
|
|
64
|
+
usage: 'off_session',
|
|
65
|
+
metadata: { app_customer_id: customer_id }
|
|
66
|
+
})
|
|
67
|
+
{ client_secret: setup_intent.client_secret, setup_intent_id: setup_intent.id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Create a Checkout Session.
|
|
71
|
+
# @param pay_customer_id [String] Stripe customer ID
|
|
72
|
+
# @param options [Hash] line_items, mode, success_url, cancel_url, metadata
|
|
73
|
+
# @return [Hash] { url:, session_id: }
|
|
74
|
+
def create_checkout_session(pay_customer_id, **options)
|
|
75
|
+
ensure_api_key!
|
|
76
|
+
params = {
|
|
77
|
+
customer: pay_customer_id,
|
|
78
|
+
line_items: options[:line_items],
|
|
79
|
+
mode: options[:mode] || 'payment',
|
|
80
|
+
success_url: options[:success_url],
|
|
81
|
+
cancel_url: options[:cancel_url],
|
|
82
|
+
metadata: options[:metadata] || {}
|
|
83
|
+
}
|
|
84
|
+
# For subscription mode, allow trial periods
|
|
85
|
+
params[:subscription_data] = options[:subscription_data] if options[:subscription_data]
|
|
86
|
+
|
|
87
|
+
session = ::Stripe::Checkout::Session.create(params)
|
|
88
|
+
{ url: session.url, session_id: session.id }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Create a subscription directly (without Checkout).
|
|
92
|
+
# @param pay_customer_id [String] Stripe customer ID
|
|
93
|
+
# @param price_id [String] Stripe price ID
|
|
94
|
+
# @param metadata [Hash]
|
|
95
|
+
# @return [Hash] { subscription_id:, status:, current_period_end: }
|
|
96
|
+
def create_subscription(pay_customer_id, price_id:, metadata: {})
|
|
97
|
+
ensure_api_key!
|
|
98
|
+
subscription = ::Stripe::Subscription.create({
|
|
99
|
+
customer: pay_customer_id,
|
|
100
|
+
items: [{ price: price_id }],
|
|
101
|
+
metadata: metadata,
|
|
102
|
+
payment_behavior: 'default_incomplete',
|
|
103
|
+
expand: ['latest_invoice.payment_intent']
|
|
104
|
+
})
|
|
105
|
+
{
|
|
106
|
+
subscription_id: subscription.id,
|
|
107
|
+
status: subscription.status,
|
|
108
|
+
current_period_end: Time.at(subscription.current_period_end).utc.iso8601
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Cancel a subscription.
|
|
113
|
+
# @param subscription_id [String] Stripe subscription ID
|
|
114
|
+
# @param immediately [Boolean] Cancel now or at period end
|
|
115
|
+
def cancel_subscription(subscription_id, immediately: false)
|
|
116
|
+
ensure_api_key!
|
|
117
|
+
if immediately
|
|
118
|
+
::Stripe::Subscription.cancel(subscription_id)
|
|
119
|
+
else
|
|
120
|
+
::Stripe::Subscription.update(subscription_id, { cancel_at_period_end: true })
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if a subscription is active.
|
|
125
|
+
# @param subscription_id [String] Stripe subscription ID
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def subscription_active?(subscription_id)
|
|
128
|
+
ensure_api_key!
|
|
129
|
+
sub = ::Stripe::Subscription.retrieve(subscription_id)
|
|
130
|
+
%w[active trialing].include?(sub.status)
|
|
131
|
+
rescue ::Stripe::InvalidRequestError
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generate a billing portal session URL.
|
|
136
|
+
# @param pay_customer_id [String] Stripe customer ID
|
|
137
|
+
# @param return_url [String]
|
|
138
|
+
# @return [Hash] { url: }
|
|
139
|
+
def billing_portal(customer, return_url:)
|
|
140
|
+
ensure_api_key!
|
|
141
|
+
pay_customer_id = customer.pay_customer_id
|
|
142
|
+
raise Error, 'Customer has no payment account' unless pay_customer_id
|
|
143
|
+
|
|
144
|
+
session = ::Stripe::BillingPortal::Session.create({
|
|
145
|
+
customer: pay_customer_id,
|
|
146
|
+
return_url: return_url
|
|
147
|
+
})
|
|
148
|
+
{ url: session.url }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Get payment method details for a customer.
|
|
152
|
+
# @param customer [Object] App model with #pay_customer_id
|
|
153
|
+
# @return [Hash, nil] { last4:, brand:, exp_month:, exp_year: }
|
|
154
|
+
def payment_method_details(customer)
|
|
155
|
+
ensure_api_key!
|
|
156
|
+
return nil unless customer.pay_customer_id
|
|
157
|
+
|
|
158
|
+
stripe_customer = ::Stripe::Customer.retrieve(customer.pay_customer_id)
|
|
159
|
+
default_pm_id = stripe_customer.invoice_settings&.default_payment_method
|
|
160
|
+
return nil unless default_pm_id
|
|
161
|
+
|
|
162
|
+
pm = ::Stripe::PaymentMethod.retrieve(default_pm_id)
|
|
163
|
+
return nil unless pm&.card
|
|
164
|
+
|
|
165
|
+
{ last4: pm.card.last4, brand: pm.card.brand,
|
|
166
|
+
exp_month: pm.card.exp_month, exp_year: pm.card.exp_year }
|
|
167
|
+
rescue ::Stripe::InvalidRequestError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Verify a webhook signature and parse the event.
|
|
172
|
+
# @param payload [String] Raw request body
|
|
173
|
+
# @param signature [String] Stripe-Signature header
|
|
174
|
+
# @return [Stripe::Event]
|
|
175
|
+
def verify_webhook(payload, signature)
|
|
176
|
+
::Stripe::Webhook.construct_event(payload, signature, webhook_signing_secret)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def fetch_api_key
|
|
182
|
+
secret_name = Belt::Pay.configuration.secret_name
|
|
183
|
+
return nil unless secret_name
|
|
184
|
+
|
|
185
|
+
require_secrets_helper
|
|
186
|
+
SecretsHelper.get_secret(secret_name: secret_name, key: 'stripe_secret_key', required: true)
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
Belt::Pay.log(:error, 'Failed to fetch Stripe API key', error: e.message)
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fetch_webhook_secret
|
|
193
|
+
secret_name = Belt::Pay.configuration.webhook_secret_name || Belt::Pay.configuration.secret_name
|
|
194
|
+
return nil unless secret_name
|
|
195
|
+
|
|
196
|
+
require_secrets_helper
|
|
197
|
+
SecretsHelper.get_secret(secret_name: secret_name, key: 'stripe_webhook_secret', required: true)
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
Belt::Pay.log(:error, 'Failed to fetch Stripe webhook secret', error: e.message)
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def require_secrets_helper
|
|
204
|
+
require 'belt/secrets_helper' if defined?(Belt::SecretsHelper)
|
|
205
|
+
rescue LoadError
|
|
206
|
+
# SecretsHelper not available — key must be set via Stripe.api_key directly
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
# Creates a SetupIntent for securely collecting payment details on the frontend.
|
|
6
|
+
class SetupIntentCreator
|
|
7
|
+
Result = Struct.new(:client_secret, :pay_customer_id, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
attr_reader :customer
|
|
10
|
+
|
|
11
|
+
def initialize(customer)
|
|
12
|
+
@customer = customer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [Result]
|
|
16
|
+
def call
|
|
17
|
+
# Ensure provider customer exists
|
|
18
|
+
pay_customer_id = CustomerProvisioner.new(customer).call
|
|
19
|
+
|
|
20
|
+
# Create setup intent via provider
|
|
21
|
+
result = Belt::Pay.provider.create_setup_intent(pay_customer_id, customer_id: customer.id)
|
|
22
|
+
|
|
23
|
+
Belt::Pay.log(:info, 'Belt::Pay::SetupIntentCreator: created',
|
|
24
|
+
customer_id: customer.id, pay_customer_id: pay_customer_id)
|
|
25
|
+
|
|
26
|
+
Result.new(client_secret: result[:client_secret], pay_customer_id: pay_customer_id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Belt
|
|
4
|
+
module Pay
|
|
5
|
+
# Manages subscriptions — create, cancel, check status.
|
|
6
|
+
module Subscription
|
|
7
|
+
class << self
|
|
8
|
+
# Create a subscription for a customer.
|
|
9
|
+
# @param customer [Object] App model with Billable included
|
|
10
|
+
# @param price_id [String] Provider price/plan ID
|
|
11
|
+
# @param metadata [Hash]
|
|
12
|
+
# @return [Hash] { subscription_id:, status:, current_period_end: }
|
|
13
|
+
def create(customer, price_id:, metadata: {})
|
|
14
|
+
CustomerProvisioner.new(customer).call
|
|
15
|
+
|
|
16
|
+
result = Belt::Pay.provider.create_subscription(
|
|
17
|
+
customer.pay_customer_id,
|
|
18
|
+
price_id: price_id,
|
|
19
|
+
metadata: metadata.merge(app_customer_id: customer.id)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Record the transaction
|
|
23
|
+
Transaction.record_subscription(
|
|
24
|
+
customer_id: customer.id,
|
|
25
|
+
subscription_id: result[:subscription_id],
|
|
26
|
+
amount_cents: 0, # Amount comes from invoice.paid webhook
|
|
27
|
+
metadata: metadata.merge(price_id: price_id)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
Belt::Pay.log(:info, 'Belt::Pay::Subscription: created',
|
|
31
|
+
customer_id: customer.id, subscription_id: result[:subscription_id],
|
|
32
|
+
status: result[:status])
|
|
33
|
+
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Cancel a subscription.
|
|
38
|
+
# @param customer [Object] App model
|
|
39
|
+
# @param immediately [Boolean]
|
|
40
|
+
def cancel(customer, immediately: false)
|
|
41
|
+
subscription_id = customer.pay_subscription_id
|
|
42
|
+
raise Error, 'Customer has no active subscription' unless subscription_id
|
|
43
|
+
|
|
44
|
+
Belt::Pay.provider.cancel_subscription(subscription_id, immediately: immediately)
|
|
45
|
+
|
|
46
|
+
Belt::Pay.log(:info, 'Belt::Pay::Subscription: canceled',
|
|
47
|
+
customer_id: customer.id, subscription_id: subscription_id,
|
|
48
|
+
immediately: immediately)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if a customer's subscription is active.
|
|
52
|
+
# @param customer [Object] App model
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def active?(customer)
|
|
55
|
+
subscription_id = customer.pay_subscription_id
|
|
56
|
+
return false unless subscription_id
|
|
57
|
+
|
|
58
|
+
Belt::Pay.provider.subscription_active?(subscription_id)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Lambda configuration for the pay_webhooks function.
|
|
2
|
+
# Handles incoming Stripe webhook events (checkout, invoice, subscription).
|
|
3
|
+
#
|
|
4
|
+
# Generated by: belt generate pay
|
|
5
|
+
#
|
|
6
|
+
# Keys:
|
|
7
|
+
# timeout Lambda timeout in seconds
|
|
8
|
+
# memory_size Lambda memory in MB
|
|
9
|
+
# env_vars Environment variables (static values or ref() for Terraform values)
|
|
10
|
+
#
|
|
11
|
+
# ref(name) references are resolved from lambda_env_refs in Terraform.
|
|
12
|
+
|
|
13
|
+
default: &default
|
|
14
|
+
timeout: 30
|
|
15
|
+
memory_size: 256
|
|
16
|
+
env_vars:
|
|
17
|
+
BELT_PAY_SECRET_NAME: ref(stripe_secret_name)
|
|
18
|
+
BELT_PAY_WEBHOOK_SECRET_NAME: ref(stripe_secret_name)
|
|
19
|
+
BELT_PAY_MODE: "live"
|
|
20
|
+
|
|
21
|
+
dev:
|
|
22
|
+
<<: *default
|
|
23
|
+
env_vars:
|
|
24
|
+
BELT_PAY_SECRET_NAME: ref(stripe_secret_name)
|
|
25
|
+
BELT_PAY_WEBHOOK_SECRET_NAME: ref(stripe_secret_name)
|
|
26
|
+
BELT_PAY_MODE: "test"
|
|
27
|
+
|
|
28
|
+
prod:
|
|
29
|
+
<<: *default
|
|
30
|
+
env_vars:
|
|
31
|
+
BELT_PAY_SECRET_NAME: ref(stripe_secret_name)
|
|
32
|
+
BELT_PAY_WEBHOOK_SECRET_NAME: ref(stripe_secret_name)
|
|
33
|
+
BELT_PAY_MODE: "live"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by: belt generate pay --controllers
|
|
4
|
+
# Override this controller to customize webhook handling behavior.
|
|
5
|
+
# The gem's default controller handles standard Stripe events automatically.
|
|
6
|
+
|
|
7
|
+
require 'belt-pay'
|
|
8
|
+
|
|
9
|
+
<% ns = @app_name.split('_').map(&:capitalize).join %>
|
|
10
|
+
module <%= ns %>Controllers
|
|
11
|
+
class PayWebhooksController < Belt::Pay::Controllers::WebhooksController
|
|
12
|
+
# Override webhook to add custom behavior before/after processing
|
|
13
|
+
# def webhook
|
|
14
|
+
# # Custom pre-processing
|
|
15
|
+
# result = super
|
|
16
|
+
# # Custom post-processing
|
|
17
|
+
# result
|
|
18
|
+
# end
|
|
19
|
+
|
|
20
|
+
# Add custom event handlers by overriding the webhook handler:
|
|
21
|
+
#
|
|
22
|
+
# Belt::Pay::WebhookHandler.define_method(:handle_custom_event) do |event_data|
|
|
23
|
+
# # Your custom logic here
|
|
24
|
+
# end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by: belt generate pay
|
|
4
|
+
# Lambda entry point for Stripe webhook events.
|
|
5
|
+
# Receives POST requests from Stripe when payment events occur.
|
|
6
|
+
|
|
7
|
+
require_relative 'config/environment'
|
|
8
|
+
require 'belt-pay'
|
|
9
|
+
|
|
10
|
+
include Belt::LambdaHandler
|
|
11
|
+
|
|
12
|
+
ROUTER = Belt::ActionRouter.new(
|
|
13
|
+
routes: [
|
|
14
|
+
{ verb: 'POST', path: '/pay/webhooks', controller: 'pay_webhooks', action: 'webhook', auth: 'none' }
|
|
15
|
+
],
|
|
16
|
+
namespace: 'pay_webhooks'
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def execute(path:, body:, event:)
|
|
20
|
+
ROUTER.route(event: event, body: body)
|
|
21
|
+
end
|