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.
- checksums.yaml +7 -0
- data/LICENSE +31 -0
- data/README.md +165 -0
- data/Rakefile +10 -0
- data/app/controllers/purchase_kit/application_controller.rb +5 -0
- data/app/controllers/purchase_kit/pay/application_controller.rb +2 -0
- data/app/controllers/purchase_kit/pay/purchase/completions_controller.rb +32 -0
- data/app/controllers/purchase_kit/pay/purchases_controller.rb +34 -0
- data/app/controllers/purchase_kit/pay/webhooks_controller.rb +35 -0
- data/app/controllers/purchase_kit/purchase/completions_controller.rb +39 -0
- data/app/controllers/purchase_kit/purchases_controller.rb +37 -0
- data/app/controllers/purchase_kit/webhooks_controller.rb +42 -0
- data/app/helpers/purchase_kit/pay/paywall_helper.rb +85 -0
- data/app/helpers/purchase_kit/paywall_helper.rb +96 -0
- data/app/javascript/controllers/purchasekit/paywall_controller.js +121 -0
- data/app/javascript/controllers/purchasekit_pay/paywall_controller.js +112 -0
- data/app/javascript/purchasekit/turbo_actions.js +4 -0
- data/app/javascript/purchasekit_pay/turbo_actions.js +4 -0
- data/app/models/pay/purchasekit/charge.rb +9 -0
- data/app/models/pay/purchasekit/customer.rb +22 -0
- data/app/models/pay/purchasekit/subscription.rb +33 -0
- data/app/views/purchase_kit/pay/purchases/_subscription_required.html.erb +3 -0
- data/app/views/purchase_kit/pay/purchases/create.turbo_stream.erb +8 -0
- data/app/views/purchase_kit/purchases/_intent.html.erb +7 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +7 -0
- data/lib/pay/purchasekit.rb +25 -0
- data/lib/purchasekit/api_client.rb +43 -0
- data/lib/purchasekit/configuration.rb +51 -0
- data/lib/purchasekit/engine.rb +38 -0
- data/lib/purchasekit/error.rb +19 -0
- data/lib/purchasekit/events.rb +112 -0
- data/lib/purchasekit/pay/configuration.rb +42 -0
- data/lib/purchasekit/pay/engine.rb +35 -0
- data/lib/purchasekit/pay/error.rb +12 -0
- data/lib/purchasekit/pay/version.rb +5 -0
- data/lib/purchasekit/pay/webhook.rb +36 -0
- data/lib/purchasekit/pay/webhooks/base.rb +25 -0
- data/lib/purchasekit/pay/webhooks/subscription_canceled.rb +11 -0
- data/lib/purchasekit/pay/webhooks/subscription_created.rb +39 -0
- data/lib/purchasekit/pay/webhooks/subscription_expired.rb +11 -0
- data/lib/purchasekit/pay/webhooks/subscription_updated.rb +31 -0
- data/lib/purchasekit/pay/webhooks.rb +11 -0
- data/lib/purchasekit/product/demo.rb +23 -0
- data/lib/purchasekit/product/remote.rb +26 -0
- data/lib/purchasekit/product.rb +52 -0
- data/lib/purchasekit/purchase/intent/demo.rb +59 -0
- data/lib/purchasekit/purchase/intent/remote.rb +37 -0
- data/lib/purchasekit/purchase/intent.rb +55 -0
- data/lib/purchasekit/version.rb +3 -0
- data/lib/purchasekit/webhook_signature.rb +60 -0
- data/lib/purchasekit-pay.rb +15 -0
- data/lib/purchasekit.rb +60 -0
- 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,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
|