nocheckout 0.1.4 → 0.2.0

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: 1940078d7a818e06033361f4c961bb23fed76c724f7273d2c9cfc7e24cf509b8
4
- data.tar.gz: 48a81f066625fd4d946358f5dd010e15703b59ad482e860493c48b28a56c95c4
3
+ metadata.gz: 36cc99edab4e1790d54150c8528af4a8909e9894c08716ee9d6893a4966772da
4
+ data.tar.gz: e909fba5b3d5dff31373ef0f268fabf3fa53297ea77d1fd09f7f13ad5147e541
5
5
  SHA512:
6
- metadata.gz: 7fb7484da98f62bef30539907231a8429f690419c9d0f0c7ac9cbf78ac9de62630391c82978dd6d1d0a9a89d3b7ffd326d3ff1858c6b7ea19afeda83d93504a9
7
- data.tar.gz: 7beaf4fe8e90dda879b1107ee8173e0cd16ebf771f2fb1d53de35dcd90abf20f03333335b2443a87c2275f5f92c8704625c300ad40c19c5e651c7dbaf871ed14
6
+ metadata.gz: b03567f074c779298ed5d3ae1b2e3bf81146b169f7002ce81ab6b8a75e399dd26c8b54833cdc3469cbe636c368365d5225c30d191341a317b22beee1f411018a
7
+ data.tar.gz: 05e63b688befdcf2f12274e7b5daac3d9ff07cf8f90749d86e08cc952947d98773d5bc9e3019f3d44d902bcbe9eb0be441430952a8101dd0cbc6ac93a394b67c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Changed
4
+ - **BREAKING**: Refactored `CheckoutSessionsController` from a base class to a composable module
5
+ - `NoCheckout::Stripe::CheckoutSession` is now a concern/module that you include in your controllers
6
+ - Controllers no longer need to inherit from `NoCheckout::Stripe::CheckoutSessionsController`
7
+ - Provides more flexibility - controllers can inherit from any base class in your app
8
+ - All protected methods (`create_checkout_session`, `retrieve_checkout_session`, `callback_url`, etc.) remain the same
9
+ - `@checkout_session` is still automatically assigned via before_action callbacks
10
+ - See README for migration examples
11
+
12
+ ### Added
13
+ - Comprehensive test suite for `CheckoutSession` module and `CheckoutSessionsController`
14
+ - Architecture documentation in README explaining the module-based design pattern
15
+
3
16
  ## [0.1.0] - 2023-09-08
4
17
 
5
18
  - Initial release
data/Gemfile.lock CHANGED
@@ -112,6 +112,7 @@ GEM
112
112
  net-smtp
113
113
  marcel (1.0.4)
114
114
  mini_mime (1.1.5)
115
+ mini_portile2 (2.8.9)
115
116
  minitest (5.25.1)
116
117
  net-imap (0.4.16)
117
118
  date
@@ -123,6 +124,9 @@ GEM
123
124
  net-smtp (0.5.0)
124
125
  net-protocol
125
126
  nio4r (2.7.3)
127
+ nokogiri (1.16.7)
128
+ mini_portile2 (~> 2.8.2)
129
+ racc (~> 1.4)
126
130
  nokogiri (1.16.7-arm64-darwin)
127
131
  racc (~> 1.4)
128
132
  nokogiri (1.16.7-x86_64-linux)
@@ -239,6 +243,7 @@ GEM
239
243
  PLATFORMS
240
244
  arm64-darwin-22
241
245
  arm64-darwin-23
246
+ arm64-darwin-24
242
247
  x86_64-linux
243
248
 
244
249
  DEPENDENCIES
@@ -248,4 +253,4 @@ DEPENDENCIES
248
253
  standard (~> 1.3)
249
254
 
250
255
  BUNDLED WITH
251
- 2.4.8
256
+ 2.6.5
data/README.md CHANGED
@@ -34,22 +34,32 @@ Stripe.api_key = Rails.configuration.stripe[:secret_key]
34
34
 
35
35
  [Stripe Checkout Sessions](https://stripe.com/docs/api/checkout/sessions) send users from your website to a branded stripe.com page where they can enter their credit card details and complete the purchase. Once the purchase is complete, the user is redirected back to your website.
36
36
 
37
- The NoCheckout::CheckoutSessionsController handles the interface between Stripe and your Rails application and tries to be as small as possible.
37
+ NoCheckout provides a `CheckoutSession` module that you can include in your controllers to handle the interface between Stripe and your Rails application. The module is designed to be minimal and flexible.
38
38
 
39
- To get started, create a base CheckoutSessionsController that maps the Users from your application with [Stripe Customers](https://stripe.com/docs/api/customers).
39
+ To get started, create a controller and include the `NoCheckout::Stripe::CheckoutSession` module. The module provides:
40
+
41
+ - **`#new` action**: Creates a Checkout Session and redirects users to Stripe
42
+ - **`#show` action**: Handles the callback when users return from Stripe
43
+ - **Protected methods**: `create_checkout_session`, `retrieve_checkout_session`, `callback_url`, `success_url`, `cancel_url`
44
+
45
+ The module automatically sets up `before_action` callbacks that:
46
+ - Call `create_checkout_session` and assign it to `@checkout_session` before the `new` action
47
+ - Call `retrieve_checkout_session` and assign it to `@checkout_session` before the `show` action
40
48
 
41
49
  ### Create user record after checkout is complete
42
50
 
43
51
  This approach creates a new user record after the checkout is complete with the name and email they give during the Stripe checkout process.
44
52
 
45
53
  ```ruby
46
- class PaymentsController < NoCheckout::Stripe::CheckoutSessionsController
47
- STRIPE_PRICE = "test_price_..."
54
+ class PaymentsController < ApplicationController
55
+ include NoCheckout::Stripe::CheckoutSession
56
+
57
+ STRIPE_PRICE = "price_..."
48
58
 
49
59
  def show
50
- # Retrieve info from Stripe
51
- customer = Stripe::Customer.retrieve checkout_session.customer
52
- subscription = Stripe::Subscription.retrieve checkout_session.subscription
60
+ # @checkout_session is automatically assigned by the module
61
+ customer = Stripe::Customer.retrieve @checkout_session.customer
62
+ subscription = Stripe::Subscription.retrieve @checkout_session.subscription
53
63
 
54
64
  # Do stuff with Stripe info
55
65
  user = User.find_or_create_by email: customer.email
@@ -76,7 +86,7 @@ class PaymentsController < NoCheckout::Stripe::CheckoutSessionsController
76
86
  end
77
87
  ```
78
88
 
79
- Then, for each product you want to offer, create a controller and inherit the `CheckoutSessionsController`.
89
+ Then, for each product you want to offer, create a controller and include the `CheckoutSession` module. You can also inherit from a base controller:
80
90
 
81
91
  ```ruby
82
92
  class PlusCheckoutSessionsController < PaymentsController
@@ -84,34 +94,70 @@ class PlusCheckoutSessionsController < PaymentsController
84
94
  end
85
95
  ```
86
96
 
97
+ Or include the module directly:
98
+
99
+ ```ruby
100
+ class ProCheckoutSessionsController < ApplicationController
101
+ include NoCheckout::Stripe::CheckoutSession
102
+
103
+ STRIPE_PRICE = "price_..."
104
+
105
+ def show
106
+ # Handle callback after successful checkout
107
+ redirect_to dashboard_url, notice: "Welcome to Pro!"
108
+ end
109
+
110
+ protected
111
+ def create_checkout_session
112
+ super \
113
+ mode: "subscription",
114
+ line_items: [{price: self.class::STRIPE_PRICE, quantity: 1}]
115
+ end
116
+ end
117
+ ```
118
+
87
119
  There's a lot of different ways you can wire up the controllers depending on how many Stripe prices are in your application. This README assumes you're selling just a few products, so the prices are hard coded as constants in the controller. This could easily be populated from a database.
88
120
 
89
121
  ### Create a user record before checkout is complete
90
122
 
91
123
  ```ruby
92
- class PaymentsController < NoCheckout::Stripe::CheckoutSessionsController
93
- before_action :authorize_user # Loads a current_user
124
+ class PaymentsController < ApplicationController
125
+ include NoCheckout::Stripe::CheckoutSession
126
+
127
+ before_action :authenticate_user! # Loads a current_user
94
128
 
95
- STRIPE_PRICE = "test_price_..."
129
+ STRIPE_PRICE = "price_..."
96
130
 
97
131
  def show
98
- customer = Stripe::Customer.retrieve checkout_session.customer
99
- subscription = Stripe::Subscription.retrieve checkout_session.subscription
132
+ # @checkout_session is automatically assigned by the module
133
+ customer = Stripe::Customer.retrieve @checkout_session.customer
134
+ subscription = Stripe::Subscription.retrieve @checkout_session.subscription
100
135
 
101
- # Do stuff with Stripe info
136
+ # Update user with subscription info
137
+ current_user.update!(
138
+ stripe_customer_id: customer.id,
139
+ stripe_subscription_id: subscription.id
140
+ )
102
141
 
103
- redirect_to root_url
142
+ redirect_to root_url, notice: "Subscription activated!"
104
143
  end
105
144
 
106
145
  protected
107
146
  def create_checkout_session
147
+ # Find or create a Stripe customer for the current user
148
+ customer = if current_user.stripe_customer_id.present?
149
+ Stripe::Customer.retrieve(current_user.stripe_customer_id)
150
+ else
151
+ Stripe::Customer.create(
152
+ email: current_user.email,
153
+ name: current_user.name,
154
+ metadata: {user_id: current_user.id}
155
+ )
156
+ end
157
+
108
158
  super \
109
159
  mode: "subscription",
110
- customer: retrieve_or_create_customer(
111
- id: current_user.id,
112
- email: current_user.email,
113
- name: current_user.name
114
- ),
160
+ customer: customer.id,
115
161
  line_items: [{
116
162
  price: self.class::STRIPE_PRICE,
117
163
  quantity: 1
@@ -120,6 +166,85 @@ class PaymentsController < NoCheckout::Stripe::CheckoutSessionsController
120
166
  end
121
167
  ```
122
168
 
169
+ ### Customizing callback URLs
170
+
171
+ By default, the module uses the same URL for both `success_url` and `cancel_url`, pointing to the `show` action with the Checkout Session ID. You can override these methods to customize the behavior:
172
+
173
+ ```ruby
174
+ class PaymentsController < ApplicationController
175
+ include NoCheckout::Stripe::CheckoutSession
176
+
177
+ protected
178
+ def success_url
179
+ # Custom success URL
180
+ callback_url(status: "success")
181
+ end
182
+
183
+ def cancel_url
184
+ # Custom cancel URL - could point to a different action or controller
185
+ pricing_url
186
+ end
187
+ end
188
+ ```
189
+
190
+ ### Routes
191
+
192
+ Don't forget to add routes for your checkout controllers:
193
+
194
+ ```ruby
195
+ # config/routes.rb
196
+ Rails.application.routes.draw do
197
+ resources :payments, controller: "payments", only: [:new, :show]
198
+
199
+ # Or for multiple products:
200
+ resource :plus_checkout, controller: "plus_checkout_sessions", only: [:new, :show]
201
+ resource :pro_checkout, controller: "pro_checkout_sessions", only: [:new, :show]
202
+ end
203
+ ```
204
+
205
+ ## Architecture
206
+
207
+ The `CheckoutSession` module is designed as a Rails concern that provides a minimal, composable interface for Stripe Checkout Sessions. Here's how it works:
208
+
209
+ ### Module-Based Design
210
+
211
+ Instead of inheriting from a base controller class, you **include** the `CheckoutSession` module into your own controllers. This gives you maximum flexibility:
212
+
213
+ - **No forced inheritance**: Your controllers can inherit from any base controller in your app
214
+ - **Composable**: Mix and match with other concerns and modules
215
+ - **Customizable**: Override any method to change behavior
216
+ - **Testable**: Each piece can be tested independently
217
+
218
+ ### What the Module Provides
219
+
220
+ 1. **Before Actions**: Automatically sets up `@checkout_session` for `new` and `show` actions
221
+ 2. **Action Methods**: Provides `#new` action that redirects to Stripe
222
+ 3. **Protected Methods**: Gives you `create_checkout_session`, `retrieve_checkout_session`, `callback_url`, `success_url`, and `cancel_url` to override as needed
223
+ 4. **Constants**: Includes helper constants for handling Stripe's callback parameter
224
+
225
+ ### Customization Pattern
226
+
227
+ The module follows a simple pattern:
228
+
229
+ ```ruby
230
+ class YourController < ApplicationController
231
+ include NoCheckout::Stripe::CheckoutSession
232
+
233
+ # 1. Override protected methods to customize Stripe session creation
234
+ protected
235
+ def create_checkout_session
236
+ super(mode: "payment", line_items: [...])
237
+ end
238
+
239
+ # 2. Implement show action to handle the callback
240
+ def show
241
+ # Access @checkout_session that was automatically retrieved
242
+ # Do your business logic
243
+ # Redirect user
244
+ end
245
+ end
246
+ ```
247
+
123
248
  ## Webhooks Controller
124
249
 
125
250
  [Stripe Webhooks](https://stripe.com/docs/webhooks) are extensive and keep your application up-to-date with what Stripe. In this example, we'll look at how to handle a subscription that's expiring and update a User record in our database.
@@ -128,8 +253,6 @@ end
128
253
  class StripesController < NoCheckout::Stripe::WebhooksController
129
254
  STRIPE_SIGNING_SECRET = ENV["STRIPE_SIGNING_SECRET"]
130
255
 
131
- protected
132
-
133
256
  def customer_subscription_created
134
257
  user.subscription_expires_at data.current_period_end
135
258
  end
@@ -0,0 +1,57 @@
1
+ module NoCheckout::Stripe
2
+ module CheckoutSession
3
+ extend ActiveSupport::Concern
4
+
5
+ # Unescaped placeholder for Stripe to insert the Checkout Session ID.
6
+ STRIPE_CALLBACK_PARAMETER = "{CHECKOUT_SESSION_ID}".freeze
7
+
8
+ # Escaped version that Rails will emit.
9
+ ESCAPED_STRIPE_CALLBACK_PARAMETER = CGI.escape(STRIPE_CALLBACK_PARAMETER).freeze
10
+
11
+ # Hoist the Stripe constant for easier access.
12
+ Stripe = ::Stripe
13
+
14
+ included do
15
+ before_action def assign_created_checkout_session
16
+ @checkout_session = create_checkout_session
17
+ end, only: :new
18
+
19
+ before_action def assign_retrieved_checkout_session
20
+ @checkout_session = retrieve_checkout_session
21
+ end, only: :show
22
+ end
23
+
24
+ # Creates a new Checkout Session and redirects to it.
25
+ def new
26
+ redirect_to @checkout_session.url, allow_other_host: true
27
+ end
28
+
29
+ protected
30
+
31
+ # Creates a Stripe Checkout Session with callback URLs appended.
32
+ def create_checkout_session(**)
33
+ Stripe::Checkout::Session.create(success_url:, cancel_url:, **)
34
+ end
35
+
36
+ # Retrieves an existing Stripe Checkout Session by ID.
37
+ def retrieve_checkout_session(*, **)
38
+ Stripe::Checkout::Session.retrieve params.fetch(:id), *, **
39
+ end
40
+
41
+ def unescape_stripe_callback_parameter(url)
42
+ url.gsub(ESCAPED_STRIPE_CALLBACK_PARAMETER, STRIPE_CALLBACK_PARAMETER)
43
+ end
44
+
45
+ # Default success URL. Override for custom behavior.
46
+ def callback_url(**)
47
+ unescape_stripe_callback_parameter url_for(
48
+ action: :show,
49
+ id: STRIPE_CALLBACK_PARAMETER,
50
+ only_path: false,
51
+ **
52
+ )
53
+ end
54
+ alias :success_url :callback_url
55
+ alias :cancel_url :callback_url
56
+ end
57
+ end
@@ -1,101 +1,5 @@
1
1
  module NoCheckout::Stripe
2
2
  class CheckoutSessionsController < ApplicationController
3
- # Name of the URL parameter stripe uses for the Checkout Session ID.
4
- CHECKOUT_SESSION_ID_KEY = :checkout_session_id
5
-
6
- def new
7
- redirect_to checkout_session.url, allow_other_host: true
8
- end
9
-
10
- protected
11
- def checkout_session
12
- @checkout_session ||= retrieve_or_create_checkout_session
13
- end
14
-
15
- # Actually creates a Stripe checkout session. The reason I had to create
16
- # this method is so I could "curry" the values within so the `create_checkout_session`
17
- # could be a bit more readable and work better with inheritance.
18
- def create_checkout_session(**attributes)
19
- Stripe::Checkout::Session.create(**append_callback_urls(**attributes))
20
- end
21
-
22
- def append_callback_urls(success_url:, cancel_url:, **attributes)
23
- attributes.merge \
24
- success_url: concat_unescaped_stripe_checkout_session_id(success_url),
25
- cancel_url: concat_unescaped_stripe_checkout_session_id(cancel_url)
26
- end
27
-
28
- def retrieve_checkout_session(id: checkout_session_id)
29
- Stripe::Checkout::Session.retrieve id
30
- end
31
-
32
- def checkout_session_id
33
- params.fetch CHECKOUT_SESSION_ID_KEY, nil
34
- end
35
-
36
- def retrieve_or_create_checkout_session
37
- if checkout_session_id.present?
38
- retrieve_checkout_session
39
- else
40
- create_checkout_session
41
- end
42
- end
43
-
44
- def callback_url_for(*args, only_path: false, **kwargs)
45
- url_for(*args, only_path: only_path, **kwargs)
46
- end
47
-
48
- STRIPE_CALLBACK_PARAMETER = "#{CHECKOUT_SESSION_ID_KEY}={CHECKOUT_SESSION_ID}"
49
-
50
- # For some reason Stripe decided to not escape the `{CHECKOUT_SESSION_ID}`, if we try to
51
- # pass it through Rails URL builders or the URI object, it will URL encode the value and
52
- # not work with stripe. Consequently, we have to do some weirdness here to append the callback.
53
- #
54
- # More information at https://stripe.com/docs/payments/checkout/custom-success-page#modify-success-url
55
- def concat_unescaped_stripe_checkout_session_id(url)
56
- if URI(url).query
57
- url.concat("&#{STRIPE_CALLBACK_PARAMETER}")
58
- else
59
- url.concat("?#{STRIPE_CALLBACK_PARAMETER}")
60
- end
61
- end
62
-
63
- # def success_url
64
- # callback_url(state: :success)
65
- # end
66
-
67
- # def cancel_url
68
- # callback_url(state: :cancel)
69
- # end
70
-
71
- # Retrives a customer from Stripe and returns a nil if the customer does not exist (instead)
72
- # of raising an exception, because this is not exceptional).
73
- def retrieve_customer(id:)
74
- return nil if customer_id.blank?
75
-
76
- begin
77
- Stripe::Customer.retrieve(String(customer_id))
78
- # Blurg ... wish Stripe just returned a response object that's not an exception.
79
- rescue Stripe::InvalidRequestError => e
80
- case e.response.data
81
- in error: { code: "resource_missing" }
82
- nil
83
- else
84
- raise
85
- end
86
- end
87
- end
88
-
89
- # Creates a customer and automatically converts the ID to a string so this
90
- # thing doesn't explode into oblivion.
91
- def create_customer(id: nil, **attributes)
92
- # If an ID is given, stripe insists that its a string.
93
- id = String(id) unless id.nil?
94
- Stripe::Customer.create(id: id, **attributes)
95
- end
96
-
97
- def retrieve_or_create_customer(id:, **attributes)
98
- retrieve_customer(id: id) || create_customer(id: id, **attributes)
99
- end
3
+ include CheckoutSession
100
4
  end
101
5
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NoCheckout
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nocheckout
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brad Gessler
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-10-07 00:00:00.000000000 Z
10
+ date: 2025-10-24 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: zeitwerk
@@ -69,6 +68,7 @@ files:
69
68
  - README.md
70
69
  - Rakefile
71
70
  - app/controllers/nocheckout/stripe.rb
71
+ - app/controllers/nocheckout/stripe/checkout_session.rb
72
72
  - app/controllers/nocheckout/stripe/checkout_sessions_controller.rb
73
73
  - app/controllers/nocheckout/stripe/webhooks_controller.rb
74
74
  - app/controllers/nocheckout/webhooks_controller.rb
@@ -87,7 +87,6 @@ metadata:
87
87
  homepage_uri: https://github.com/rubymonolith/nocheckout
88
88
  source_code_uri: https://github.com/rubymonolith/nocheckout
89
89
  changelog_uri: https://github.com/rubymonolith/nocheckout
90
- post_install_message:
91
90
  rdoc_options: []
92
91
  require_paths:
93
92
  - lib
@@ -102,8 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
102
101
  - !ruby/object:Gem::Version
103
102
  version: '0'
104
103
  requirements: []
105
- rubygems_version: 3.5.9
106
- signing_key:
104
+ rubygems_version: 3.6.2
107
105
  specification_version: 4
108
106
  summary: Rails controllers for Stripe Checkout Sessions and Webhooks
109
107
  test_files: []