purchasekit-pay 0.1.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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +159 -0
  4. data/Rakefile +10 -0
  5. data/app/controllers/purchase_kit/pay/application_controller.rb +2 -0
  6. data/app/controllers/purchase_kit/pay/purchases_controller.rb +32 -0
  7. data/app/controllers/purchase_kit/pay/webhooks_controller.rb +32 -0
  8. data/app/helpers/purchase_kit/pay/paywall_helper.rb +85 -0
  9. data/app/javascript/controllers/purchasekit_pay/paywall_controller.js +111 -0
  10. data/app/javascript/purchasekit_pay/turbo_actions.js +4 -0
  11. data/app/models/pay/purchasekit/charge.rb +9 -0
  12. data/app/models/pay/purchasekit/customer.rb +22 -0
  13. data/app/models/pay/purchasekit/subscription.rb +33 -0
  14. data/app/views/purchase_kit/pay/purchases/_subscription_required.html.erb +3 -0
  15. data/app/views/purchase_kit/pay/purchases/create.turbo_stream.erb +7 -0
  16. data/config/importmap.rb +8 -0
  17. data/config/routes.rb +4 -0
  18. data/lib/pay/purchasekit.rb +25 -0
  19. data/lib/purchasekit/api_client.rb +38 -0
  20. data/lib/purchasekit/pay/configuration.rb +23 -0
  21. data/lib/purchasekit/pay/engine.rb +35 -0
  22. data/lib/purchasekit/pay/error.rb +12 -0
  23. data/lib/purchasekit/pay/version.rb +5 -0
  24. data/lib/purchasekit/pay/webhook.rb +36 -0
  25. data/lib/purchasekit/pay/webhooks/base.rb +25 -0
  26. data/lib/purchasekit/pay/webhooks/subscription_canceled.rb +11 -0
  27. data/lib/purchasekit/pay/webhooks/subscription_created.rb +39 -0
  28. data/lib/purchasekit/pay/webhooks/subscription_expired.rb +11 -0
  29. data/lib/purchasekit/pay/webhooks/subscription_updated.rb +32 -0
  30. data/lib/purchasekit/pay/webhooks.rb +11 -0
  31. data/lib/purchasekit/product.rb +38 -0
  32. data/lib/purchasekit/purchase/intent.rb +41 -0
  33. data/lib/purchasekit-pay.rb +11 -0
  34. metadata +153 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e5043601031b8440098cdc344c6a9986d3add5bce85f1c5523dfa9506c05a363
4
+ data.tar.gz: e3570d5b0d442e19144b797c964923818d02f477596f0d6eb542a458cb99842a
5
+ SHA512:
6
+ metadata.gz: f7a68f3b375bfd13268907f4c3002d1b728b75fbbfc08221aea8e0ee2c753c334fc56b786b0d89e68e6c8d08467fb0e8e4869855547601fe54e83662027f6e4e
7
+ data.tar.gz: e09eafd1dd15c6d228cfa57d460fd4f78f98dae7b02da02cac200bfe36610d0fe0e311fba6f430cb22117e793c57e419552986d182580c1be02847fa313f32a2
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2025 Joe Masilotti
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # PurchaseKit::Pay
2
+
3
+ PurchaseKit payment processor for the [Pay gem](https://github.com/pay-rails/pay).
4
+
5
+ Add mobile in-app purchases (IAP) to your Rails app with [PurchaseKit](https://purchasekit.dev) and Pay.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem "purchasekit-pay"
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ Configure your PurchaseKit API key:
24
+
25
+ ```ruby
26
+ # config/initializers/purchasekit.rb
27
+ PurchaseKit::Pay.configure do |config|
28
+ config.api_key = Rails.application.credentials.dig(:purchasekit, :api_key)
29
+ config.app_id = Rails.application.credentials.dig(:purchasekit, :app_id)
30
+ config.webhook_secret = Rails.application.credentials.dig(:purchasekit, :webhook_secret)
31
+ end
32
+ ```
33
+
34
+ Mount the engine in your routes:
35
+
36
+ ```ruby
37
+ # config/routes.rb
38
+ mount PurchaseKit::Pay::Engine, at: "/purchasekit"
39
+ ```
40
+
41
+ ### JavaScript setup
42
+
43
+ Import the Turbo Stream actions in your application:
44
+
45
+ ```javascript
46
+ // app/javascript/application.js
47
+ import "purchasekit-pay/turbo_actions"
48
+ ```
49
+
50
+ Register the gem's Stimulus controllers:
51
+
52
+ ```javascript
53
+ // app/javascript/controllers/index.js
54
+ import { application } from "controllers/application"
55
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
56
+
57
+ eagerLoadControllersFrom("controllers", application)
58
+ eagerLoadControllersFrom("purchasekit-pay", application)
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ### Adding a paywall
64
+
65
+ First, ensure your user has a PurchaseKit payment processor:
66
+
67
+ ```ruby
68
+ current_user.set_payment_processor(:purchasekit)
69
+ ```
70
+
71
+ Fetch products in your controller:
72
+
73
+ ```ruby
74
+ @annual = PurchaseKit::Product.find("prod_XXX")
75
+ @monthly = PurchaseKit::Product.find("prod_YYY")
76
+ ```
77
+
78
+ Then render a paywall using the builder pattern:
79
+
80
+ ```erb
81
+ <%# Subscribe to ActionCable for real-time redirect after purchase %>
82
+ <%= turbo_stream_from dom_id(current_user.payment_processor) %>
83
+
84
+ <%= purchasekit_paywall customer: current_user.payment_processor, success_path: dashboard_path do |paywall| %>
85
+ <%= paywall.plan_option product: @annual, selected: true do %>
86
+ <span>Annual</span>
87
+ <%= paywall.price %>
88
+ <% end %>
89
+
90
+ <%= paywall.plan_option product: @monthly do %>
91
+ <span>Monthl</span>
92
+ <%= paywall.price %>
93
+ <% end %>
94
+
95
+ <%= paywall.submit "Subscribe", class: "btn btn-primary" %>
96
+ <%= paywall.restore_link %>
97
+ <% end %>
98
+ ```
99
+
100
+ ### Paywall helper options
101
+
102
+ - `customer:` (required) - A `Pay::Customer` instance
103
+ - `success_path:` - Where to redirect after successful purchase (defaults to `root_path`)
104
+
105
+ ### Builder methods
106
+
107
+ - `plan_option(product:, selected: false)` - Radio button and label for a plan
108
+ - `price` - Displays the localized price (must be inside `plan_option` block)
109
+ - `submit(text)` - Submit button (disabled until prices load)
110
+ - `restore_link(text: "Restore purchases")` - Link to restore previous purchases
111
+
112
+ ### How it works
113
+
114
+ 1. Page loads, Stimulus controller requests prices from native app via Hotwire Native Bridge
115
+ 2. User selects a plan and taps subscribe
116
+ 3. Form submits to PurchasesController, which creates a purchase intent with PurchaseKit
117
+ 4. Native app handles the App Store/Play Store purchase flow
118
+ 5. PurchaseKit receives webhook from Apple/Google, normalizes it, and POSTs to your app
119
+ 6. Webhook handler creates `Pay::Subscription` and broadcasts a Turbo Stream redirect
120
+ 7. User is redirected to `success_path`
121
+
122
+ ## Webhook events
123
+
124
+ The gem handles these webhook events from the PurchaseKit:
125
+
126
+ - `subscription.created` - Creates a new `Pay::Subscription`
127
+ - `subscription.updated` - Updates subscription status and period
128
+ - `subscription.canceled` - Marks subscription as canceled
129
+ - `subscription.expired` - Marks subscription as expired
130
+
131
+ ## Requirements
132
+
133
+ - Ruby 3.1+
134
+ - Rails 7.0 - 8.x
135
+ - Pay 11.4+
136
+ - Turbo Rails (for ActionCable broadcasts)
137
+ - Stimulus
138
+ - Hotwire Native app with PurchaseKit bridge component
139
+
140
+ ### JavaScript dependencies
141
+
142
+ This gem vendors and pins:
143
+
144
+ - **@hotwired/hotwire-native-bridge** (v1.2.2)
145
+
146
+ If your app already pins this package, your version takes precedence.
147
+
148
+ ## Development
149
+
150
+ After cloning the repo, run:
151
+
152
+ ```bash
153
+ bundle install
154
+ bundle exec rake test
155
+ ```
156
+
157
+ ## License
158
+
159
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
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.test_files = FileList["test/**/*_test.rb"].exclude("test/dummy/**/*")
7
+ t.verbose = false
8
+ end
9
+
10
+ task default: :test
@@ -0,0 +1,2 @@
1
+ class PurchaseKit::Pay::ApplicationController < ActionController::Base
2
+ end
@@ -0,0 +1,32 @@
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
+ respond_to do |format|
15
+ format.turbo_stream
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def subscription_required(exception)
22
+ respond_to do |format|
23
+ format.turbo_stream do
24
+ render turbo_stream: turbo_stream.replace(
25
+ "purchasekit_paywall",
26
+ partial: "purchase_kit/pay/purchases/subscription_required",
27
+ locals: {message: exception.message}
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
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
+ return JSON.parse(payload, symbolize_names: true) if secret.blank?
20
+
21
+ raise SignatureVerificationError, "Missing signature" if signature.blank?
22
+
23
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
24
+ unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
25
+ raise SignatureVerificationError, "Invalid signature"
26
+ end
27
+
28
+ JSON.parse(payload, symbolize_names: true)
29
+ end
30
+
31
+ class SignatureVerificationError < StandardError; end
32
+ 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,111 @@
1
+ import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
2
+
3
+ export default class extends BridgeComponent {
4
+ static component = "paywall"
5
+ static targets = ["planRadio", "price", "submitButton", "response", "environment"]
6
+
7
+ connect() {
8
+ super.connect()
9
+ this.#fetchPrices()
10
+ }
11
+
12
+ responseTargetConnected(element) {
13
+ const correlationId = element.dataset.correlationId
14
+ const productIds = this.#productIds(element)
15
+
16
+ element.remove()
17
+ this.#disableForm()
18
+ this.#triggerNativePurchase(productIds, correlationId)
19
+ }
20
+
21
+ restore(event) {
22
+ event.preventDefault()
23
+ this.send("restore")
24
+ }
25
+
26
+ #triggerNativePurchase(productIds, correlationId) {
27
+ this.send("purchase", { ...productIds, correlationId }, message => {
28
+ const { status, error } = message.data
29
+
30
+ if (error) {
31
+ console.error(error)
32
+ alert(`Purchase error: ${error}`)
33
+ this.#enableForm()
34
+ }
35
+
36
+ if (status == "cancelled") {
37
+ this.#enableForm()
38
+ }
39
+
40
+ // On success, keep showing processing state.
41
+ // Turbo Stream will update the UI when webhook completes.
42
+ })
43
+ }
44
+
45
+ #fetchPrices() {
46
+ const products = this.priceTargets.map(el => this.#productIds(el))
47
+
48
+ this.send("prices", { products }, message => {
49
+ const { prices, environment, error } = message.data
50
+
51
+ if (error) {
52
+ console.error(error)
53
+ return
54
+ }
55
+
56
+ if (prices) {
57
+ this.#setPrices(prices)
58
+ this.#setEnvironment(environment)
59
+ this.#enableForm()
60
+ }
61
+ })
62
+ }
63
+
64
+ #setEnvironment(environment) {
65
+ if (this.hasEnvironmentTarget && environment) {
66
+ this.environmentTarget.value = environment
67
+ }
68
+ }
69
+
70
+ #setPrices(prices) {
71
+ this.priceTargets.forEach(el => {
72
+ const { appleStoreProductId, googleStoreProductId } = this.#productIds(el)
73
+ const price = prices[appleStoreProductId] || prices[googleStoreProductId]
74
+
75
+ if (price) {
76
+ el.textContent = price
77
+ } else {
78
+ console.error(`No price found for product.`)
79
+ }
80
+ })
81
+ }
82
+
83
+ #productIds(element) {
84
+ return {
85
+ appleStoreProductId: element.dataset.appleStoreProductId,
86
+ googleStoreProductId: element.dataset.googleStoreProductId
87
+ }
88
+ }
89
+
90
+ #enableForm() {
91
+ this.planRadioTargets.forEach(radio => radio.disabled = false)
92
+ if (this.hasSubmitButtonTarget) {
93
+ this.submitButtonTarget.disabled = false
94
+ if (this.#originalButtonText) {
95
+ this.submitButtonTarget.innerHTML = this.#originalButtonText
96
+ }
97
+ }
98
+ }
99
+
100
+ #disableForm() {
101
+ this.planRadioTargets.forEach(radio => radio.disabled = true)
102
+ if (this.hasSubmitButtonTarget) {
103
+ this.#originalButtonText = this.submitButtonTarget.innerHTML
104
+ this.submitButtonTarget.disabled = true
105
+ const processingText = this.submitButtonTarget.dataset.processingText || "Processing..."
106
+ this.submitButtonTarget.innerHTML = processingText
107
+ }
108
+ }
109
+
110
+ #originalButtonText = null
111
+ }
@@ -0,0 +1,4 @@
1
+ // Custom Turbo Stream action for redirects after new subscription.
2
+ Turbo.StreamActions.redirect = function() {
3
+ Turbo.visit(this.getAttribute("url"), {action: "replace"})
4
+ }
@@ -0,0 +1,9 @@
1
+ module Pay
2
+ module Purchasekit
3
+ class Charge < Pay::Charge
4
+ def refund!(amount = nil)
5
+ raise Pay::Purchasekit::Error, "Refunds must be processed through App Store Connect or Google Play Console."
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ module Pay
2
+ module Purchasekit
3
+ class Customer < Pay::Customer
4
+ # Use base class name for dom_id to keep stream names consistent
5
+ def self.model_name
6
+ Pay::Customer.model_name
7
+ end
8
+
9
+ def charge(amount, options = {})
10
+ raise Pay::Purchasekit::Error, "One-time charges not supported. Use in-app purchases."
11
+ end
12
+
13
+ def subscribe(name: Pay.default_product_name, plan: Pay.default_plan_name, **options)
14
+ raise Pay::Purchasekit::Error, "Subscriptions must be initiated through the native app."
15
+ end
16
+
17
+ def add_payment_method(payment_method_id, default: false)
18
+ raise Pay::Purchasekit::Error, "Payment methods managed by App Store or Google Play."
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,33 @@
1
+ module Pay
2
+ module Purchasekit
3
+ class Subscription < Pay::Subscription
4
+ def cancel(**options)
5
+ raise Pay::Purchasekit::Error, "Cancel through App Store or Google Play."
6
+ end
7
+
8
+ def cancel_now!(**options)
9
+ raise Pay::Purchasekit::Error, "Cancel through App Store or Google Play."
10
+ end
11
+
12
+ def resume
13
+ raise Pay::Purchasekit::Error, "Resume through App Store or Google Play."
14
+ end
15
+
16
+ def swap(plan, **options)
17
+ raise Pay::Purchasekit::Error, "Change plans through App Store or Google Play."
18
+ end
19
+
20
+ def change_quantity(quantity, **options)
21
+ raise Pay::Purchasekit::Error, "Quantity changes not supported for in-app purchases."
22
+ end
23
+
24
+ def paused?
25
+ false
26
+ end
27
+
28
+ def pause(**options)
29
+ raise Pay::Purchasekit::Error, "Pause through App Store or Google Play."
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ <div id="purchasekit_paywall" class="purchasekit-error">
2
+ <p><%= message %></p>
3
+ </div>
@@ -0,0 +1,7 @@
1
+ <%= turbo_stream.append "purchasekit_paywall" do %>
2
+ <div data-purchasekit-pay--paywall-target="response"
3
+ data-correlation-id="<%= @intent.uuid %>"
4
+ data-apple-store-product-id="<%= @intent.product.apple_product_id %>"
5
+ data-google-store-product-id="<%= @intent.product.google_product_id %>">
6
+ </div>
7
+ <% end %>
@@ -0,0 +1,8 @@
1
+ # Pin Hotwire Native Bridge dependency
2
+ # Note: If your app already pins this package, your version will take precedence
3
+ # since the app's importmap.rb loads after the gem's importmap.rb
4
+ pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js" # @1.2.2
5
+
6
+ pin_all_from PurchaseKit::Pay::Engine.root.join("app/javascript/controllers"), under: "purchasekit-pay", to: "controllers"
7
+
8
+ pin "purchasekit-pay/turbo_actions", to: "purchasekit_pay/turbo_actions.js"
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ PurchaseKit::Pay::Engine.routes.draw do
2
+ resources :purchases, only: [:create]
3
+ resource :webhooks, only: [:create]
4
+ end
@@ -0,0 +1,25 @@
1
+ module Pay
2
+ module Purchasekit
3
+ class Error < Pay::Error
4
+ end
5
+
6
+ module_function
7
+
8
+ def enabled?
9
+ true
10
+ end
11
+
12
+ def setup
13
+ # No setup required
14
+ end
15
+
16
+ def configure_webhooks
17
+ Pay::Webhooks.configure do |events|
18
+ events.subscribe "purchasekit.subscription.created", PurchaseKit::Pay::Webhooks::SubscriptionCreated.new
19
+ events.subscribe "purchasekit.subscription.updated", PurchaseKit::Pay::Webhooks::SubscriptionUpdated.new
20
+ events.subscribe "purchasekit.subscription.canceled", PurchaseKit::Pay::Webhooks::SubscriptionCanceled.new
21
+ events.subscribe "purchasekit.subscription.expired", PurchaseKit::Pay::Webhooks::SubscriptionExpired.new
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ require "httparty"
2
+
3
+ module PurchaseKit
4
+ class ApiClient
5
+ def get(path)
6
+ request(:get, path)
7
+ end
8
+
9
+ def post(path, body = {})
10
+ request(:post, path, body)
11
+ end
12
+
13
+ private
14
+
15
+ def request(method, path, body = nil)
16
+ options = {headers: headers}
17
+ options[:body] = body.to_json if body
18
+
19
+ HTTParty.public_send(method, url(path), options)
20
+ end
21
+
22
+ def url(path)
23
+ "#{config.api_url}/api/v1/apps/#{config.app_id}#{path}"
24
+ end
25
+
26
+ def headers
27
+ {
28
+ "Authorization" => "Bearer #{config.api_key}",
29
+ "Accept" => "application/json",
30
+ "Content-Type" => "application/json"
31
+ }
32
+ end
33
+
34
+ def config
35
+ PurchaseKit::Pay.config
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ class Configuration
4
+ attr_accessor :api_key, :api_url, :app_id, :webhook_secret
5
+
6
+ def initialize
7
+ @api_url = "https://purchasekit.dev"
8
+ end
9
+ end
10
+
11
+ class << self
12
+ attr_writer :config
13
+
14
+ def config
15
+ @config ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(config)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace PurchaseKit::Pay
5
+
6
+ initializer "purchasekit_pay.register_processor", before: :load_config_initializers do
7
+ ::Pay.enabled_processors << :purchasekit unless ::Pay.enabled_processors.include?(:purchasekit)
8
+ end
9
+
10
+ initializer "purchasekit_pay.webhooks", after: :load_config_initializers do
11
+ ::Pay::Purchasekit.configure_webhooks
12
+ end
13
+
14
+ initializer "purchasekit_pay.helpers" do
15
+ ActiveSupport.on_load(:action_controller_base) do
16
+ helper PurchaseKit::Pay::PaywallHelper
17
+ end
18
+ end
19
+
20
+ initializer "purchasekit_pay.importmap", before: "importmap" do |app|
21
+ if app.config.respond_to?(:importmap)
22
+ app.config.importmap.paths << Engine.root.join("config/importmap.rb")
23
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
24
+ end
25
+ end
26
+
27
+ initializer "purchasekit_pay.assets" do |app|
28
+ if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths)
29
+ app.config.assets.paths << root.join("app/javascript")
30
+ app.config.assets.precompile += %w[purchasekit-pay/manifest.js]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,12 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ class Error < ::Pay::Error
4
+ end
5
+
6
+ class NotFoundError < Error
7
+ end
8
+
9
+ class SubscriptionRequiredError < Error
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,36 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ class Webhook
4
+ def self.queue(event)
5
+ new(event).queue
6
+ end
7
+
8
+ def initialize(event)
9
+ @event = event
10
+ end
11
+
12
+ def queue
13
+ return unless listening?
14
+
15
+ record = ::Pay::Webhook.create!(processor: :purchasekit, event_type:, event: @event)
16
+ ProcessWebhookJob.perform_later(record.id)
17
+ end
18
+
19
+ private
20
+
21
+ def event_type
22
+ @event[:type]
23
+ end
24
+
25
+ def listening?
26
+ ::Pay::Webhooks.delegator.listening?("purchasekit.#{event_type}")
27
+ end
28
+
29
+ class ProcessWebhookJob < ::ActiveJob::Base
30
+ def perform(webhook_id)
31
+ ::Pay::Webhook.find(webhook_id).process!
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,25 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ class Base
5
+ private
6
+
7
+ def update_subscription(event, attributes)
8
+ pay_subscription = find_subscription(event)
9
+ return unless pay_subscription
10
+
11
+ pay_subscription.update!(attributes)
12
+ end
13
+
14
+ def find_subscription(event)
15
+ ::Pay::Subscription.find_by(processor_id: event["subscription_id"])
16
+ end
17
+
18
+ def parse_time(value)
19
+ return nil if value.blank?
20
+ Time.zone.parse(value)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,11 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ class SubscriptionCanceled < Base
5
+ def call(event)
6
+ update_subscription(event, status: :canceled, ends_at: parse_time(event["ends_at"]))
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ class SubscriptionCreated < Base
5
+ include ActionView::RecordIdentifier
6
+ include Turbo::Streams::ActionHelper
7
+
8
+ def call(event)
9
+ customer = ::Pay::Customer.find(event["customer_id"])
10
+
11
+ subscription = ::Pay::Purchasekit::Subscription.find_or_initialize_by(
12
+ customer: customer,
13
+ processor_id: event["subscription_id"]
14
+ )
15
+ is_new = subscription.new_record?
16
+
17
+ subscription.update!(
18
+ name: event["subscription_name"] || ::Pay.default_product_name,
19
+ processor_plan: event["store_product_id"],
20
+ status: :active,
21
+ quantity: 1,
22
+ current_period_start: parse_time(event["current_period_start"]),
23
+ current_period_end: parse_time(event["current_period_end"]),
24
+ ends_at: parse_time(event["ends_at"]),
25
+ data: (subscription.data || {}).merge("store" => event["store"])
26
+ )
27
+
28
+ return unless is_new
29
+
30
+ redirect_path = event["success_path"] || Rails.application.routes.url_helpers.root_path
31
+ Turbo::StreamsChannel.broadcast_stream_to(
32
+ dom_id(customer),
33
+ content: turbo_stream_action_tag(:redirect, url: redirect_path)
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,11 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ class SubscriptionExpired < Base
5
+ def call(event)
6
+ update_subscription(event, status: :expired)
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,32 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ class SubscriptionUpdated < Base
5
+ include ActionView::RecordIdentifier
6
+ include Turbo::Streams::ActionHelper
7
+
8
+ def call(event)
9
+ update_subscription(event,
10
+ processor_plan: event["store_product_id"],
11
+ status: event["status"],
12
+ current_period_start: parse_time(event["current_period_start"]),
13
+ current_period_end: parse_time(event["current_period_end"]),
14
+ ends_at: parse_time(event["ends_at"])
15
+ )
16
+
17
+ broadcast_redirect(event) if event["success_path"].present?
18
+ end
19
+
20
+ private
21
+
22
+ def broadcast_redirect(event)
23
+ customer = ::Pay::Customer.find(event["customer_id"])
24
+ Turbo::StreamsChannel.broadcast_stream_to(
25
+ dom_id(customer),
26
+ content: turbo_stream_action_tag(:redirect, url: event["success_path"])
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ module PurchaseKit
2
+ module Pay
3
+ module Webhooks
4
+ autoload :Base, "purchasekit/pay/webhooks/base"
5
+ autoload :SubscriptionCreated, "purchasekit/pay/webhooks/subscription_created"
6
+ autoload :SubscriptionUpdated, "purchasekit/pay/webhooks/subscription_updated"
7
+ autoload :SubscriptionCanceled, "purchasekit/pay/webhooks/subscription_canceled"
8
+ autoload :SubscriptionExpired, "purchasekit/pay/webhooks/subscription_expired"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ module PurchaseKit
2
+ class Product
3
+ attr_reader :id, :apple_product_id, :google_product_id
4
+
5
+ def initialize(id:, apple_product_id: nil, google_product_id: nil)
6
+ @id = id
7
+ @apple_product_id = apple_product_id
8
+ @google_product_id = google_product_id
9
+ end
10
+
11
+ def store_product_id(platform:)
12
+ case platform
13
+ when :apple then apple_product_id
14
+ when :google then google_product_id
15
+ else raise ArgumentError, "Unknown platform: #{platform}"
16
+ end
17
+ end
18
+
19
+ class << self
20
+ def find(id)
21
+ response = ApiClient.new.get("/products/#{id}")
22
+
23
+ case response.code
24
+ when 200
25
+ new(
26
+ id: response["id"],
27
+ apple_product_id: response["apple_product_id"],
28
+ google_product_id: response["google_product_id"]
29
+ )
30
+ when 404
31
+ raise PurchaseKit::Pay::NotFoundError, "Product not found: #{id}"
32
+ else
33
+ raise PurchaseKit::Pay::Error, "API error: #{response.code} #{response.message}"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ module PurchaseKit
2
+ module Purchase
3
+ class Intent
4
+ attr_reader :id, :uuid, :product
5
+
6
+ def initialize(id:, uuid:, product:)
7
+ @id = id
8
+ @uuid = uuid
9
+ @product = product
10
+ end
11
+
12
+ class << self
13
+ def create(product_id:, customer_id:, success_path: nil, environment: nil)
14
+ response = ApiClient.new.post("/purchase/intents", {
15
+ product_id: product_id,
16
+ customer_id: customer_id,
17
+ success_path: success_path,
18
+ environment: environment
19
+ })
20
+
21
+ case response.code
22
+ when 201
23
+ product_data = response["product"]
24
+ product = Product.new(
25
+ id: product_data["id"],
26
+ apple_product_id: product_data["apple_product_id"],
27
+ google_product_id: product_data["google_product_id"]
28
+ )
29
+ new(id: response["id"], uuid: response["uuid"], product: product)
30
+ when 402
31
+ raise PurchaseKit::Pay::SubscriptionRequiredError, response["error"] || "Subscription required for production purchases"
32
+ when 404
33
+ raise PurchaseKit::Pay::NotFoundError, "App or product not found"
34
+ else
35
+ raise PurchaseKit::Pay::Error, "API error: #{response.code} #{response.message}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ require "pay"
2
+ require "purchasekit/pay/version"
3
+ require "purchasekit/pay/configuration"
4
+ require "purchasekit/pay/error"
5
+ require "purchasekit/pay/engine"
6
+ require "purchasekit/pay/webhooks"
7
+ require "purchasekit/pay/webhook"
8
+ require "purchasekit/api_client"
9
+ require "purchasekit/product"
10
+ require "purchasekit/purchase/intent"
11
+ require "pay/purchasekit"
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: purchasekit-pay
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joe Masilotti
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httparty
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.22'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.22'
27
+ - !ruby/object:Gem::Dependency
28
+ name: pay
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '11.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '11.4'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ - - "<"
49
+ - !ruby/object:Gem::Version
50
+ version: '9'
51
+ type: :runtime
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '7.0'
58
+ - - "<"
59
+ - !ruby/object:Gem::Version
60
+ version: '9'
61
+ - !ruby/object:Gem::Dependency
62
+ name: vcr
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '6.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '6.0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: webmock
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ description: Add mobile in-app purchases to your Rails app with PurchaseKit and Pay.
90
+ email:
91
+ - joe@masilotti.com
92
+ executables: []
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - MIT-LICENSE
97
+ - README.md
98
+ - Rakefile
99
+ - app/controllers/purchase_kit/pay/application_controller.rb
100
+ - app/controllers/purchase_kit/pay/purchases_controller.rb
101
+ - app/controllers/purchase_kit/pay/webhooks_controller.rb
102
+ - app/helpers/purchase_kit/pay/paywall_helper.rb
103
+ - app/javascript/controllers/purchasekit_pay/paywall_controller.js
104
+ - app/javascript/purchasekit_pay/turbo_actions.js
105
+ - app/models/pay/purchasekit/charge.rb
106
+ - app/models/pay/purchasekit/customer.rb
107
+ - app/models/pay/purchasekit/subscription.rb
108
+ - app/views/purchase_kit/pay/purchases/_subscription_required.html.erb
109
+ - app/views/purchase_kit/pay/purchases/create.turbo_stream.erb
110
+ - config/importmap.rb
111
+ - config/routes.rb
112
+ - lib/pay/purchasekit.rb
113
+ - lib/purchasekit-pay.rb
114
+ - lib/purchasekit/api_client.rb
115
+ - lib/purchasekit/pay/configuration.rb
116
+ - lib/purchasekit/pay/engine.rb
117
+ - lib/purchasekit/pay/error.rb
118
+ - lib/purchasekit/pay/version.rb
119
+ - lib/purchasekit/pay/webhook.rb
120
+ - lib/purchasekit/pay/webhooks.rb
121
+ - lib/purchasekit/pay/webhooks/base.rb
122
+ - lib/purchasekit/pay/webhooks/subscription_canceled.rb
123
+ - lib/purchasekit/pay/webhooks/subscription_created.rb
124
+ - lib/purchasekit/pay/webhooks/subscription_expired.rb
125
+ - lib/purchasekit/pay/webhooks/subscription_updated.rb
126
+ - lib/purchasekit/product.rb
127
+ - lib/purchasekit/purchase/intent.rb
128
+ homepage: https://purchasekit.dev
129
+ licenses:
130
+ - MIT
131
+ metadata:
132
+ homepage_uri: https://purchasekit.dev
133
+ source_code_uri: https://github.com/purchasekit/purchasekit-pay
134
+ post_install_message:
135
+ rdoc_options: []
136
+ require_paths:
137
+ - lib
138
+ required_ruby_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: '3.1'
143
+ required_rubygems_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ requirements: []
149
+ rubygems_version: 3.4.10
150
+ signing_key:
151
+ specification_version: 4
152
+ summary: PurchaseKit payment processor for Pay
153
+ test_files: []