purchasekit 0.2.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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +31 -0
  3. data/README.md +165 -0
  4. data/Rakefile +10 -0
  5. data/app/controllers/purchase_kit/application_controller.rb +5 -0
  6. data/app/controllers/purchase_kit/pay/application_controller.rb +2 -0
  7. data/app/controllers/purchase_kit/pay/purchase/completions_controller.rb +32 -0
  8. data/app/controllers/purchase_kit/pay/purchases_controller.rb +34 -0
  9. data/app/controllers/purchase_kit/pay/webhooks_controller.rb +35 -0
  10. data/app/controllers/purchase_kit/purchase/completions_controller.rb +39 -0
  11. data/app/controllers/purchase_kit/purchases_controller.rb +37 -0
  12. data/app/controllers/purchase_kit/webhooks_controller.rb +42 -0
  13. data/app/helpers/purchase_kit/pay/paywall_helper.rb +85 -0
  14. data/app/helpers/purchase_kit/paywall_helper.rb +96 -0
  15. data/app/javascript/controllers/purchasekit/paywall_controller.js +121 -0
  16. data/app/javascript/controllers/purchasekit_pay/paywall_controller.js +112 -0
  17. data/app/javascript/purchasekit/turbo_actions.js +4 -0
  18. data/app/javascript/purchasekit_pay/turbo_actions.js +4 -0
  19. data/app/models/pay/purchasekit/charge.rb +9 -0
  20. data/app/models/pay/purchasekit/customer.rb +22 -0
  21. data/app/models/pay/purchasekit/subscription.rb +33 -0
  22. data/app/views/purchase_kit/pay/purchases/_subscription_required.html.erb +3 -0
  23. data/app/views/purchase_kit/pay/purchases/create.turbo_stream.erb +8 -0
  24. data/app/views/purchase_kit/purchases/_intent.html.erb +7 -0
  25. data/config/importmap.rb +3 -0
  26. data/config/routes.rb +7 -0
  27. data/lib/pay/purchasekit.rb +25 -0
  28. data/lib/purchasekit/api_client.rb +43 -0
  29. data/lib/purchasekit/configuration.rb +51 -0
  30. data/lib/purchasekit/engine.rb +38 -0
  31. data/lib/purchasekit/error.rb +19 -0
  32. data/lib/purchasekit/events.rb +112 -0
  33. data/lib/purchasekit/pay/configuration.rb +42 -0
  34. data/lib/purchasekit/pay/engine.rb +35 -0
  35. data/lib/purchasekit/pay/error.rb +12 -0
  36. data/lib/purchasekit/pay/version.rb +5 -0
  37. data/lib/purchasekit/pay/webhook.rb +36 -0
  38. data/lib/purchasekit/pay/webhooks/base.rb +25 -0
  39. data/lib/purchasekit/pay/webhooks/subscription_canceled.rb +11 -0
  40. data/lib/purchasekit/pay/webhooks/subscription_created.rb +39 -0
  41. data/lib/purchasekit/pay/webhooks/subscription_expired.rb +11 -0
  42. data/lib/purchasekit/pay/webhooks/subscription_updated.rb +31 -0
  43. data/lib/purchasekit/pay/webhooks.rb +11 -0
  44. data/lib/purchasekit/product/demo.rb +23 -0
  45. data/lib/purchasekit/product/remote.rb +26 -0
  46. data/lib/purchasekit/product.rb +52 -0
  47. data/lib/purchasekit/purchase/intent/demo.rb +59 -0
  48. data/lib/purchasekit/purchase/intent/remote.rb +37 -0
  49. data/lib/purchasekit/purchase/intent.rb +55 -0
  50. data/lib/purchasekit/version.rb +3 -0
  51. data/lib/purchasekit/webhook_signature.rb +60 -0
  52. data/lib/purchasekit-pay.rb +15 -0
  53. data/lib/purchasekit.rb +60 -0
  54. metadata +189 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3aead88093b6325a3d7c6530a49999c88870fd7ce8c377923eafb3f42c14d490
4
+ data.tar.gz: 94921560fdf9ac5f393b0931a9c8f6b396ebea604c2c59bda694732bc90b0d4d
5
+ SHA512:
6
+ metadata.gz: ce11f8ab3f94e75c62d843adc274dcc5d898ce08ffbd4cfc554d25a6150278fc2eb71f60ee1131d2d0e9756ab5a560805585dbc9a8be49c2e6c8f620dfefa9c7
7
+ data.tar.gz: eb6d567280bcfd93fe8dc07585acb0159a86b95b564be6f112f7368532fdaaeae349cd2d61608db783bddf966855bcf610e7adcccf0c9474e32567ee1f54a389
data/LICENSE ADDED
@@ -0,0 +1,31 @@
1
+ PurchaseKit License
2
+
3
+ Copyright (c) 2025 Joe Masilotti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to use,
7
+ copy, modify, and distribute the Software, subject to the following conditions:
8
+
9
+ 1. The Software may only be used in applications that actively integrate with
10
+ the official PurchaseKit service operated by the copyright holder
11
+ (https://purchasekit.dev or successor URLs). Use of the Software requires
12
+ a valid PurchaseKit account and active data transmission to PurchaseKit.
13
+
14
+ 2. The Software may not be used:
15
+ a. In any application that does not integrate with PurchaseKit, including
16
+ internal tools, personal projects, or proprietary systems.
17
+ b. To build, operate, or integrate with any competing in-app purchase
18
+ management service.
19
+ c. To create a self-hosted, forked, or alternative version of PurchaseKit.
20
+ d. As a standalone library independent of PurchaseKit.
21
+
22
+ 3. The above copyright notice and this permission notice shall be included
23
+ in all copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # PurchaseKit
2
+
3
+ In-app purchase webhooks for Rails. Receive normalized Apple and Google subscription events with a simple callback interface.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ Native app (iOS/Android)
9
+ ↓ StoreKit/Play Billing
10
+ App Store / Play Store
11
+ ↓ Server-to-server notifications
12
+ PurchaseKit SaaS (normalizes Apple/Google data)
13
+ ↓ Webhooks
14
+ Your Rails app (via this gem)
15
+ ↓ Callbacks or Pay::Subscription
16
+ Your business logic
17
+ ```
18
+
19
+ PurchaseKit handles the complexity of Apple and Google's different webhook formats, delivering you a consistent event payload regardless of which store the purchase came from.
20
+
21
+ ## Installation
22
+
23
+ Add to your Gemfile:
24
+
25
+ ```ruby
26
+ gem "purchasekit"
27
+ ```
28
+
29
+ Create an initializer:
30
+
31
+ ```ruby
32
+ # config/initializers/purchasekit.rb
33
+ PurchaseKit.configure do |config|
34
+ config.api_key = Rails.application.credentials.dig(:purchasekit, :api_key)
35
+ config.app_id = Rails.application.credentials.dig(:purchasekit, :app_id)
36
+ config.webhook_secret = Rails.application.credentials.dig(:purchasekit, :webhook_secret)
37
+ end
38
+ ```
39
+
40
+ Mount the engine in your routes:
41
+
42
+ ```ruby
43
+ # config/routes.rb
44
+ mount PurchaseKit::Engine, at: "/purchasekit"
45
+ ```
46
+
47
+ Import the JavaScript:
48
+
49
+ ```javascript
50
+ // app/javascript/application.js
51
+ import "purchasekit/turbo_actions"
52
+
53
+ // app/javascript/controllers/index.js
54
+ eagerLoadControllersFrom("purchasekit", application)
55
+ ```
56
+
57
+ ## Pay gem integration
58
+
59
+ If you use the [Pay gem](https://github.com/pay-rails/pay), PurchaseKit automatically detects it and handles everything:
60
+
61
+ ```ruby
62
+ gem "pay"
63
+ gem "purchasekit"
64
+ ```
65
+
66
+ When Pay is detected, webhooks automatically create and update `Pay::Subscription` records and broadcast Turbo Stream redirects. No event callbacks needed.
67
+
68
+ ## Event callbacks (without Pay)
69
+
70
+ If you're not using Pay, register callbacks to handle subscription events:
71
+
72
+ ```ruby
73
+ # config/initializers/purchasekit.rb
74
+ PurchaseKit.configure do |config|
75
+ # ... credentials ...
76
+
77
+ config.on(:subscription_created) do |event|
78
+ user = User.find(event.customer_id)
79
+ user.subscriptions.create!(
80
+ processor_id: event.subscription_id,
81
+ store: event.store,
82
+ status: event.status
83
+ )
84
+ end
85
+
86
+ config.on(:subscription_canceled) do |event|
87
+ subscription = Subscription.find_by(processor_id: event.subscription_id)
88
+ subscription&.update!(status: "canceled")
89
+ end
90
+
91
+ config.on(:subscription_expired) do |event|
92
+ subscription = Subscription.find_by(processor_id: event.subscription_id)
93
+ subscription&.update!(status: "expired")
94
+ end
95
+ end
96
+ ```
97
+
98
+ ### Available events
99
+
100
+ | Event | Description |
101
+ |-------|-------------|
102
+ | `:subscription_created` | New subscription started |
103
+ | `:subscription_updated` | Subscription renewed or plan changed |
104
+ | `:subscription_canceled` | User canceled (still active until `ends_at`) |
105
+ | `:subscription_expired` | Subscription ended |
106
+
107
+ ### Event payload
108
+
109
+ | Method | Description |
110
+ |--------|-------------|
111
+ | `event.customer_id` | Your user ID |
112
+ | `event.subscription_id` | Store's subscription ID |
113
+ | `event.store` | `"apple"` or `"google"` |
114
+ | `event.store_product_id` | e.g., `"com.example.pro.annual"` |
115
+ | `event.status` | `"active"`, `"canceled"`, `"expired"` |
116
+ | `event.current_period_start` | Start of billing period |
117
+ | `event.current_period_end` | End of billing period |
118
+ | `event.ends_at` | When subscription will end |
119
+ | `event.success_path` | Redirect path after purchase |
120
+
121
+ ## Paywall helper
122
+
123
+ Build a paywall using the included helper:
124
+
125
+ ```erb
126
+ <%= purchasekit_paywall customer_id: current_user.id, success_path: dashboard_path do |paywall| %>
127
+ <%= paywall.plan_option product: @annual, selected: true do %>
128
+ Annual - <%= paywall.price %>/year
129
+ <% end %>
130
+
131
+ <%= paywall.plan_option product: @monthly do %>
132
+ Monthly - <%= paywall.price %>/month
133
+ <% end %>
134
+
135
+ <%= paywall.submit "Subscribe" %>
136
+ <%= paywall.restore_link %>
137
+ <% end %>
138
+ ```
139
+
140
+ Products are fetched from the PurchaseKit API:
141
+
142
+ ```ruby
143
+ @annual = PurchaseKit::Product.find("prod_XXXXXXXX")
144
+ @monthly = PurchaseKit::Product.find("prod_YYYYYYYY")
145
+ ```
146
+
147
+ ## Demo mode
148
+
149
+ For local development without a PurchaseKit account:
150
+
151
+ ```ruby
152
+ PurchaseKit.configure do |config|
153
+ config.demo_mode = true
154
+ config.demo_products = {
155
+ "prod_annual" => { apple_product_id: "com.example.pro.annual" },
156
+ "prod_monthly" => { apple_product_id: "com.example.pro.monthly" }
157
+ }
158
+ end
159
+ ```
160
+
161
+ Works with Xcode's StoreKit local testing.
162
+
163
+ ## License
164
+
165
+ This software is licensed under a custom PurchaseKit License. The gem may only be used in applications that actively integrate with the official PurchaseKit service at https://purchasekit.dev. See LICENSE for full details.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,5 @@
1
+ module PurchaseKit
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ class PurchaseKit::Pay::ApplicationController < ActionController::Base
2
+ end
@@ -0,0 +1,32 @@
1
+ class PurchaseKit::Pay::Purchase::CompletionsController < PurchaseKit::Pay::ApplicationController
2
+ include ActionView::RecordIdentifier
3
+ include Turbo::Streams::ActionHelper
4
+
5
+ skip_before_action :verify_authenticity_token
6
+
7
+ def create
8
+ unless PurchaseKit::Pay.config.demo_mode?
9
+ head :not_found
10
+ return
11
+ end
12
+
13
+ intent = PurchaseKit::Purchase::Intent::Demo.find(params[:intent_uuid])
14
+ customer = ::Pay::Customer.find(intent.customer_id)
15
+
16
+ customer.subscriptions.create!(
17
+ name: "default",
18
+ processor_id: "demo_#{SecureRandom.hex(12)}",
19
+ processor_plan: intent.product.apple_product_id,
20
+ status: "active",
21
+ quantity: 1
22
+ )
23
+
24
+ redirect_path = intent.success_path || "/"
25
+ Turbo::StreamsChannel.broadcast_stream_to(
26
+ dom_id(customer),
27
+ content: turbo_stream_action_tag(:redirect, url: redirect_path)
28
+ )
29
+
30
+ head :ok
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ class PurchaseKit::Pay::PurchasesController < PurchaseKit::Pay::ApplicationController
2
+ rescue_from PurchaseKit::Pay::SubscriptionRequiredError, with: :subscription_required
3
+
4
+ def create
5
+ @customer = ::Pay::Customer.find(params[:customer_id])
6
+
7
+ @intent = PurchaseKit::Purchase::Intent.create(
8
+ product_id: params[:product_id],
9
+ customer_id: @customer.id,
10
+ success_path: params[:success_path],
11
+ environment: params[:environment]
12
+ )
13
+
14
+ @xcode_completion_url = PurchaseKit::Pay.config.xcode_completion_url(intent_uuid: @intent.uuid, host: request.base_url)
15
+
16
+ respond_to do |format|
17
+ format.turbo_stream
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def subscription_required(exception)
24
+ respond_to do |format|
25
+ format.turbo_stream do
26
+ render turbo_stream: turbo_stream.replace(
27
+ "purchasekit_paywall",
28
+ partial: "purchase_kit/pay/purchases/subscription_required",
29
+ locals: {message: exception.message}
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ class PurchaseKit::Pay::WebhooksController < PurchaseKit::Pay::ApplicationController
2
+ skip_forgery_protection
3
+
4
+ def create
5
+ PurchaseKit::Pay::Webhook.queue(verified_event)
6
+ head :ok
7
+ rescue SignatureVerificationError => e
8
+ Rails.logger.error "[PurchaseKit] Webhook signature error: #{e.message}"
9
+ head :bad_request
10
+ end
11
+
12
+ private
13
+
14
+ def verified_event
15
+ payload = request.raw_post
16
+ signature = request.headers["X-PurchaseKit-Signature"]
17
+ secret = PurchaseKit::Pay.config.webhook_secret
18
+
19
+ if secret.blank?
20
+ raise SignatureVerificationError, "webhook_secret must be configured" if Rails.env.production?
21
+ return JSON.parse(payload, symbolize_names: true)
22
+ end
23
+
24
+ raise SignatureVerificationError, "Missing signature" if signature.blank?
25
+
26
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
27
+ unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
28
+ raise SignatureVerificationError, "Invalid signature"
29
+ end
30
+
31
+ JSON.parse(payload, symbolize_names: true)
32
+ end
33
+
34
+ class SignatureVerificationError < StandardError; end
35
+ end
@@ -0,0 +1,39 @@
1
+ module PurchaseKit
2
+ module Purchase
3
+ # Demo mode only - simulates purchase completion for Xcode StoreKit testing
4
+ class CompletionsController < ApplicationController
5
+ skip_forgery_protection
6
+
7
+ def create
8
+ return head :not_found unless PurchaseKit.config.demo_mode?
9
+
10
+ intent = Intent::Demo.find(params[:id])
11
+
12
+ # Simulate a subscription.created webhook
13
+ event = {
14
+ type: "subscription.created",
15
+ customer_id: intent.customer_id.to_s,
16
+ subscription_id: "sub_#{SecureRandom.hex(12)}",
17
+ store: "apple",
18
+ store_product_id: intent.product.apple_product_id,
19
+ subscription_name: "Demo Subscription",
20
+ status: "active",
21
+ current_period_start: Time.current.iso8601,
22
+ current_period_end: 1.year.from_now.iso8601,
23
+ ends_at: nil,
24
+ success_path: intent.success_path
25
+ }
26
+
27
+ # Publish the event (triggers callbacks and Pay integration if available)
28
+ PurchaseKit::Events.publish(:subscription_created, event)
29
+
30
+ # Queue for Pay if available
31
+ PurchaseKit::Pay::Webhook.queue(event) if PurchaseKit.pay_enabled?
32
+
33
+ redirect_to intent.success_path || main_app.root_path, notice: "Purchase completed!"
34
+ rescue PurchaseKit::NotFoundError
35
+ head :not_found
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ module PurchaseKit
2
+ class PurchasesController < ApplicationController
3
+ def create
4
+ intent = PurchaseKit::Purchase::Intent.create(
5
+ product_id: params[:product_id],
6
+ customer_id: params[:customer_id],
7
+ success_path: params[:success_path],
8
+ environment: params[:environment]
9
+ )
10
+
11
+ respond_to do |format|
12
+ format.turbo_stream do
13
+ render turbo_stream: turbo_stream.append(
14
+ "purchasekit_paywall",
15
+ partial: "purchase_kit/purchases/intent",
16
+ locals: {intent: intent}
17
+ )
18
+ end
19
+ format.json { render json: intent_json(intent) }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def intent_json(intent)
26
+ {
27
+ id: intent.id,
28
+ uuid: intent.uuid,
29
+ product: {
30
+ id: intent.product.id,
31
+ apple_product_id: intent.product.apple_product_id,
32
+ google_product_id: intent.product.google_product_id
33
+ }
34
+ }
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ module PurchaseKit
2
+ class WebhooksController < ApplicationController
3
+ skip_forgery_protection
4
+
5
+ def create
6
+ event = verified_event
7
+ event_type = event[:type].to_s.tr(".", "_").to_sym
8
+
9
+ # Publish to event system (fires registered callbacks)
10
+ PurchaseKit::Events.publish(event_type, event)
11
+
12
+ # Queue for Pay's webhook processing if Pay is available
13
+ PurchaseKit.queue_pay_webhook(event) if PurchaseKit.pay_enabled?
14
+
15
+ head :ok
16
+ rescue PurchaseKit::SignatureVerificationError => e
17
+ Rails.logger.error "[PurchaseKit] Webhook signature error: #{e.message}"
18
+ head :bad_request
19
+ end
20
+
21
+ private
22
+
23
+ def verified_event
24
+ payload = request.raw_post
25
+ signature = request.headers["X-PurchaseKit-Signature"]
26
+ secret = PurchaseKit.config.webhook_secret
27
+
28
+ if secret.blank?
29
+ if Rails.env.production?
30
+ raise PurchaseKit::SignatureVerificationError, "webhook_secret must be configured"
31
+ end
32
+ return JSON.parse(payload, symbolize_names: true)
33
+ end
34
+
35
+ PurchaseKit::WebhookSignature.verified_payload(
36
+ payload: payload,
37
+ signature: signature,
38
+ secret: secret
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,85 @@
1
+ module PurchaseKit::Pay::PaywallHelper
2
+ # Wrapper for paywall with bridge controller
3
+ # Renders a form that posts to the purchases endpoint
4
+ # Yields a builder for plan options and buttons
5
+ # Requires a Pay::Customer - call user.set_payment_processor(:purchasekit) first
6
+ def purchasekit_paywall(customer:, success_path: main_app.root_path, **options)
7
+ raise ArgumentError, "Must provide customer: parameter. Call user.set_payment_processor(:purchasekit) first." unless customer
8
+
9
+ builder = PurchaseKit::Pay::PaywallBuilder.new(self, customer)
10
+
11
+ form_data = (options.delete(:data) || {}).merge(
12
+ controller: "purchasekit-pay--paywall",
13
+ purchasekit_pay__paywall_customer_id_value: customer.id
14
+ )
15
+
16
+ form_with(url: purchasekit_pay.purchases_path, id: "purchasekit_paywall", data: form_data, **options) do |form|
17
+ hidden = hidden_field_tag(:customer_id, customer.id)
18
+ hidden += hidden_field_tag(:success_path, success_path)
19
+ hidden += hidden_field_tag(:environment, "sandbox", data: {purchasekit_pay__paywall_target: "environment"})
20
+ hidden + capture { yield builder }
21
+ end
22
+ end
23
+ end
24
+
25
+ class PurchaseKit::Pay::PaywallBuilder
26
+ def initialize(template, customer)
27
+ @template = template
28
+ @customer = customer
29
+ @current_product = nil
30
+ end
31
+
32
+ def plan_option(product:, selected: false, input_class: nil, **options, &block)
33
+ input_id = "purchasekit_plan_#{product.id.parameterize.underscore}"
34
+
35
+ radio = @template.radio_button_tag(
36
+ :product_id,
37
+ product.id,
38
+ selected,
39
+ id: input_id,
40
+ class: input_class,
41
+ autocomplete: "off",
42
+ data: {
43
+ purchasekit_pay__paywall_target: "planRadio",
44
+ apple_store_product_id: product.apple_product_id,
45
+ google_store_product_id: product.google_product_id
46
+ }
47
+ )
48
+
49
+ @current_product = product
50
+ label = @template.label_tag(input_id, **options) { @template.capture(&block) }
51
+ @current_product = nil
52
+
53
+ radio + label
54
+ end
55
+
56
+ def price(**options, &block)
57
+ raise "price must be called within a plan_option block" unless @current_product
58
+
59
+ data = (options.delete(:data) || {}).merge(
60
+ purchasekit_pay__paywall_target: "price",
61
+ apple_store_product_id: @current_product.apple_product_id,
62
+ google_store_product_id: @current_product.google_product_id
63
+ )
64
+
65
+ loading_content = block ? @template.capture(&block) : "Loading..."
66
+ @template.content_tag(:span, loading_content, data: data, **options)
67
+ end
68
+
69
+ def submit(text = "Subscribe", **options)
70
+ data = (options.delete(:data) || {}).merge(
71
+ purchasekit_pay__paywall_target: "submitButton",
72
+ turbo_submits_with: text
73
+ )
74
+
75
+ @template.submit_tag(text, disabled: true, data: data, **options)
76
+ end
77
+
78
+ def restore_link(text: "Restore purchases", **options)
79
+ data = (options.delete(:data) || {}).merge(
80
+ action: "click->purchasekit-pay--paywall#restore"
81
+ )
82
+
83
+ @template.link_to(text, "#", data: data, **options)
84
+ end
85
+ end
@@ -0,0 +1,96 @@
1
+ module PurchaseKit
2
+ module PaywallHelper
3
+ # Renders a paywall form that triggers native in-app purchases
4
+ #
5
+ # @param customer_id [String, Integer] Your user/customer identifier
6
+ # @param success_path [String] Where to redirect after successful purchase
7
+ # @yield [PaywallBuilder] Builder for plan options and buttons
8
+ #
9
+ # Example:
10
+ # <%= purchasekit_paywall customer_id: current_user.id, success_path: dashboard_path do |paywall| %>
11
+ # <%= paywall.plan_option product: @annual, selected: true do %>
12
+ # Annual - <%= paywall.price %>
13
+ # <% end %>
14
+ # <%= paywall.submit "Subscribe" %>
15
+ # <% end %>
16
+ #
17
+ def purchasekit_paywall(customer_id:, success_path: main_app.root_path, **options)
18
+ raise ArgumentError, "customer_id is required" if customer_id.blank?
19
+
20
+ builder = PaywallBuilder.new(self)
21
+
22
+ form_data = (options.delete(:data) || {}).merge(
23
+ controller: "purchasekit--paywall",
24
+ purchasekit__paywall_customer_id_value: customer_id
25
+ )
26
+
27
+ form_with(url: purchasekit.purchases_path, id: "purchasekit_paywall", data: form_data, **options) do |form|
28
+ hidden = hidden_field_tag(:customer_id, customer_id)
29
+ hidden += hidden_field_tag(:success_path, success_path)
30
+ hidden += hidden_field_tag(:environment, "sandbox", data: {purchasekit__paywall_target: "environment"})
31
+ hidden + capture { yield builder }
32
+ end
33
+ end
34
+ end
35
+
36
+ class PaywallBuilder
37
+ def initialize(template)
38
+ @template = template
39
+ @current_product = nil
40
+ end
41
+
42
+ def plan_option(product:, selected: false, input_class: nil, **options, &block)
43
+ input_id = "purchasekit_plan_#{product.id.to_s.parameterize.underscore}"
44
+
45
+ radio = @template.radio_button_tag(
46
+ :product_id,
47
+ product.id,
48
+ selected,
49
+ id: input_id,
50
+ class: input_class,
51
+ autocomplete: "off",
52
+ data: {
53
+ purchasekit__paywall_target: "planRadio",
54
+ apple_store_product_id: product.apple_product_id,
55
+ google_store_product_id: product.google_product_id
56
+ }
57
+ )
58
+
59
+ @current_product = product
60
+ label = @template.label_tag(input_id, **options) { @template.capture(&block) }
61
+ @current_product = nil
62
+
63
+ radio + label
64
+ end
65
+
66
+ def price(**options, &block)
67
+ raise "price must be called within a plan_option block" unless @current_product
68
+
69
+ data = (options.delete(:data) || {}).merge(
70
+ purchasekit__paywall_target: "price",
71
+ apple_store_product_id: @current_product.apple_product_id,
72
+ google_store_product_id: @current_product.google_product_id
73
+ )
74
+
75
+ loading_content = block ? @template.capture(&block) : "Loading..."
76
+ @template.content_tag(:span, loading_content, data: data, **options)
77
+ end
78
+
79
+ def submit(text = "Subscribe", **options)
80
+ data = (options.delete(:data) || {}).merge(
81
+ purchasekit__paywall_target: "submitButton",
82
+ turbo_submits_with: text
83
+ )
84
+
85
+ @template.submit_tag(text, disabled: true, data: data, **options)
86
+ end
87
+
88
+ def restore_link(text: "Restore purchases", **options)
89
+ data = (options.delete(:data) || {}).merge(
90
+ action: "click->purchasekit--paywall#restore"
91
+ )
92
+
93
+ @template.link_to(text, "#", data: data, **options)
94
+ end
95
+ end
96
+ end