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
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
const relativeUrl = element.dataset.xcodeCompletionUrl
|
|
16
|
+
const xcodeCompletionUrl = relativeUrl ? `${window.location.origin}${relativeUrl}` : null
|
|
17
|
+
const successPath = element.dataset.successPath
|
|
18
|
+
|
|
19
|
+
element.remove()
|
|
20
|
+
this.#disableForm()
|
|
21
|
+
this.#triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl, successPath)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
restore(event) {
|
|
25
|
+
event.preventDefault()
|
|
26
|
+
this.send("restore")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl, successPath) {
|
|
30
|
+
this.send("purchase", { ...productIds, correlationId, xcodeCompletionUrl }, message => {
|
|
31
|
+
const { status, error } = message.data
|
|
32
|
+
|
|
33
|
+
if (error) {
|
|
34
|
+
console.error(error)
|
|
35
|
+
alert(`Purchase error: ${error}`)
|
|
36
|
+
this.#enableForm()
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (status == "cancelled") {
|
|
41
|
+
this.#enableForm()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// On success, redirect to success path after a short delay
|
|
46
|
+
// to allow the completion callback to finish processing
|
|
47
|
+
if (status == "success" && successPath) {
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
window.Turbo.visit(successPath)
|
|
50
|
+
}, 500)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#fetchPrices() {
|
|
56
|
+
const products = this.priceTargets.map(el => this.#productIds(el))
|
|
57
|
+
|
|
58
|
+
this.send("prices", { products }, message => {
|
|
59
|
+
const { prices, environment, error } = message.data
|
|
60
|
+
|
|
61
|
+
if (error) {
|
|
62
|
+
console.error(error)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (prices) {
|
|
67
|
+
this.#setPrices(prices)
|
|
68
|
+
this.#setEnvironment(environment)
|
|
69
|
+
this.#enableForm()
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#setEnvironment(environment) {
|
|
75
|
+
if (this.hasEnvironmentTarget && environment) {
|
|
76
|
+
this.environmentTarget.value = environment
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#setPrices(prices) {
|
|
81
|
+
this.priceTargets.forEach(el => {
|
|
82
|
+
const { appleStoreProductId, googleStoreProductId } = this.#productIds(el)
|
|
83
|
+
const price = prices[appleStoreProductId] || prices[googleStoreProductId]
|
|
84
|
+
|
|
85
|
+
if (price) {
|
|
86
|
+
el.textContent = price
|
|
87
|
+
} else {
|
|
88
|
+
console.error(`No price found for product.`)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#productIds(element) {
|
|
94
|
+
return {
|
|
95
|
+
appleStoreProductId: element.dataset.appleStoreProductId,
|
|
96
|
+
googleStoreProductId: element.dataset.googleStoreProductId
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#enableForm() {
|
|
101
|
+
this.planRadioTargets.forEach(radio => radio.disabled = false)
|
|
102
|
+
if (this.hasSubmitButtonTarget) {
|
|
103
|
+
this.submitButtonTarget.disabled = false
|
|
104
|
+
if (this.#originalButtonText) {
|
|
105
|
+
this.submitButtonTarget.innerHTML = this.#originalButtonText
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#disableForm() {
|
|
111
|
+
this.planRadioTargets.forEach(radio => radio.disabled = true)
|
|
112
|
+
if (this.hasSubmitButtonTarget) {
|
|
113
|
+
this.#originalButtonText = this.submitButtonTarget.innerHTML
|
|
114
|
+
this.submitButtonTarget.disabled = true
|
|
115
|
+
const processingText = this.submitButtonTarget.dataset.processingText || "Processing..."
|
|
116
|
+
this.submitButtonTarget.innerHTML = processingText
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#originalButtonText = null
|
|
121
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
const xcodeCompletionUrl = element.dataset.xcodeCompletionUrl
|
|
16
|
+
|
|
17
|
+
element.remove()
|
|
18
|
+
this.#disableForm()
|
|
19
|
+
this.#triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
restore(event) {
|
|
23
|
+
event.preventDefault()
|
|
24
|
+
this.send("restore")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl) {
|
|
28
|
+
this.send("purchase", { ...productIds, correlationId, xcodeCompletionUrl }, message => {
|
|
29
|
+
const { status, error } = message.data
|
|
30
|
+
|
|
31
|
+
if (error) {
|
|
32
|
+
console.error(error)
|
|
33
|
+
alert(`Purchase error: ${error}`)
|
|
34
|
+
this.#enableForm()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (status == "cancelled") {
|
|
38
|
+
this.#enableForm()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// On success, keep showing processing state.
|
|
42
|
+
// Turbo Stream will update the UI when webhook completes.
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#fetchPrices() {
|
|
47
|
+
const products = this.priceTargets.map(el => this.#productIds(el))
|
|
48
|
+
|
|
49
|
+
this.send("prices", { products }, message => {
|
|
50
|
+
const { prices, environment, error } = message.data
|
|
51
|
+
|
|
52
|
+
if (error) {
|
|
53
|
+
console.error(error)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (prices) {
|
|
58
|
+
this.#setPrices(prices)
|
|
59
|
+
this.#setEnvironment(environment)
|
|
60
|
+
this.#enableForm()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#setEnvironment(environment) {
|
|
66
|
+
if (this.hasEnvironmentTarget && environment) {
|
|
67
|
+
this.environmentTarget.value = environment
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#setPrices(prices) {
|
|
72
|
+
this.priceTargets.forEach(el => {
|
|
73
|
+
const { appleStoreProductId, googleStoreProductId } = this.#productIds(el)
|
|
74
|
+
const price = prices[appleStoreProductId] || prices[googleStoreProductId]
|
|
75
|
+
|
|
76
|
+
if (price) {
|
|
77
|
+
el.textContent = price
|
|
78
|
+
} else {
|
|
79
|
+
console.error(`No price found for product.`)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#productIds(element) {
|
|
85
|
+
return {
|
|
86
|
+
appleStoreProductId: element.dataset.appleStoreProductId,
|
|
87
|
+
googleStoreProductId: element.dataset.googleStoreProductId
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#enableForm() {
|
|
92
|
+
this.planRadioTargets.forEach(radio => radio.disabled = false)
|
|
93
|
+
if (this.hasSubmitButtonTarget) {
|
|
94
|
+
this.submitButtonTarget.disabled = false
|
|
95
|
+
if (this.#originalButtonText) {
|
|
96
|
+
this.submitButtonTarget.innerHTML = this.#originalButtonText
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#disableForm() {
|
|
102
|
+
this.planRadioTargets.forEach(radio => radio.disabled = true)
|
|
103
|
+
if (this.hasSubmitButtonTarget) {
|
|
104
|
+
this.#originalButtonText = this.submitButtonTarget.innerHTML
|
|
105
|
+
this.submitButtonTarget.disabled = true
|
|
106
|
+
const processingText = this.submitButtonTarget.dataset.processingText || "Processing..."
|
|
107
|
+
this.submitButtonTarget.innerHTML = processingText
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#originalButtonText = null
|
|
112
|
+
}
|
|
@@ -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,8 @@
|
|
|
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
|
+
data-xcode-completion-url="<%= @xcode_completion_url %>">
|
|
7
|
+
</div>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div data-purchasekit--paywall-target="response"
|
|
2
|
+
data-correlation-id="<%= intent.uuid %>"
|
|
3
|
+
data-apple-store-product-id="<%= intent.product.apple_product_id %>"
|
|
4
|
+
data-google-store-product-id="<%= intent.product.google_product_id %>"
|
|
5
|
+
data-xcode-completion-url="<%= intent.xcode_completion_url %>"
|
|
6
|
+
data-success-path="<%= intent.success_path %>">
|
|
7
|
+
</div>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
pin "@hotwired/hotwire-native-bridge", to: "https://cdn.jsdelivr.net/npm/@hotwired/hotwire-native-bridge@1.2.2/dist/hotwire-native-bridge.js"
|
|
2
|
+
pin_all_from PurchaseKit::Engine.root.join("app/javascript/controllers/purchasekit"), under: "controllers/purchasekit"
|
|
3
|
+
pin_all_from PurchaseKit::Engine.root.join("app/javascript/purchasekit"), under: "purchasekit"
|
data/config/routes.rb
ADDED
|
@@ -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,43 @@
|
|
|
1
|
+
require "httparty"
|
|
2
|
+
|
|
3
|
+
module PurchaseKit
|
|
4
|
+
# HTTP client for the PurchaseKit SaaS API.
|
|
5
|
+
#
|
|
6
|
+
# Used internally by Product and Purchase::Intent to communicate
|
|
7
|
+
# with the PurchaseKit backend.
|
|
8
|
+
#
|
|
9
|
+
class ApiClient
|
|
10
|
+
def get(path)
|
|
11
|
+
request(:get, path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def post(path, body = {})
|
|
15
|
+
request(:post, path, body)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def request(method, path, body = nil)
|
|
21
|
+
options = {headers: headers}
|
|
22
|
+
options[:body] = body.to_json if body
|
|
23
|
+
|
|
24
|
+
HTTParty.public_send(method, url(path), options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def url(path)
|
|
28
|
+
"#{config.base_api_url}/apps/#{config.app_id}#{path}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def headers
|
|
32
|
+
{
|
|
33
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
34
|
+
"Accept" => "application/json",
|
|
35
|
+
"Content-Type" => "application/json"
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def config
|
|
40
|
+
PurchaseKit.config
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :api_key, :api_url, :app_id, :webhook_secret
|
|
4
|
+
attr_accessor :demo_mode, :demo_products
|
|
5
|
+
|
|
6
|
+
def initialize
|
|
7
|
+
@api_url = "https://purchasekit.dev"
|
|
8
|
+
@demo_mode = false
|
|
9
|
+
@demo_products = {}
|
|
10
|
+
@event_handlers = Hash.new { |h, k| h[k] = [] }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def demo_mode?
|
|
14
|
+
@demo_mode
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def base_api_url
|
|
18
|
+
"#{api_url}/api/v1"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Register a callback for an event type.
|
|
22
|
+
#
|
|
23
|
+
# Available events:
|
|
24
|
+
# - :subscription_created
|
|
25
|
+
# - :subscription_updated
|
|
26
|
+
# - :subscription_canceled
|
|
27
|
+
# - :subscription_expired
|
|
28
|
+
#
|
|
29
|
+
# Example:
|
|
30
|
+
# PurchaseKit.configure do |config|
|
|
31
|
+
# config.on(:subscription_created) do |event|
|
|
32
|
+
# user = User.find(event.customer_id)
|
|
33
|
+
# user.update!(subscribed: true)
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
def on(event_type, &block)
|
|
38
|
+
@event_handlers[event_type.to_sym] << block
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get handlers for an event type (internal use)
|
|
42
|
+
def handlers_for(event_type)
|
|
43
|
+
@event_handlers[event_type.to_sym]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if any handlers are registered for an event type
|
|
47
|
+
def listening?(event_type)
|
|
48
|
+
@event_handlers[event_type.to_sym].any?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace PurchaseKit
|
|
4
|
+
|
|
5
|
+
initializer "purchasekit.helpers" do
|
|
6
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
7
|
+
helper PurchaseKit::PaywallHelper
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "purchasekit.importmap", before: "importmap" do |app|
|
|
12
|
+
if app.config.respond_to?(:importmap)
|
|
13
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
|
14
|
+
app.config.importmap.cache_sweepers << Engine.root.join("app/javascript")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "purchasekit.assets" do |app|
|
|
19
|
+
if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:paths)
|
|
20
|
+
app.config.assets.paths << Engine.root.join("app/javascript")
|
|
21
|
+
app.config.assets.precompile += %w[purchasekit/manifest.js]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Pay gem integration (only when Pay is available)
|
|
26
|
+
initializer "purchasekit.pay_processor", before: :load_config_initializers do
|
|
27
|
+
if PurchaseKit.pay_enabled?
|
|
28
|
+
::Pay.enabled_processors << :purchasekit unless ::Pay.enabled_processors.include?(:purchasekit)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer "purchasekit.pay_webhooks", after: :load_config_initializers do
|
|
33
|
+
if PurchaseKit.pay_enabled?
|
|
34
|
+
::Pay::Purchasekit.configure_webhooks
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
# Base error class for all PurchaseKit errors
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
# Raised when a resource is not found (404)
|
|
7
|
+
class NotFoundError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Raised when a PurchaseKit subscription is required (402)
|
|
11
|
+
# This happens when trying to create purchase intents in production
|
|
12
|
+
# without an active PurchaseKit subscription
|
|
13
|
+
class SubscriptionRequiredError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when webhook signature verification fails
|
|
17
|
+
class SignatureVerificationError < Error
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
module Events
|
|
3
|
+
TYPES = %i[
|
|
4
|
+
subscription_created
|
|
5
|
+
subscription_updated
|
|
6
|
+
subscription_canceled
|
|
7
|
+
subscription_expired
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# Publish an event to all registered handlers.
|
|
12
|
+
#
|
|
13
|
+
# Also publishes via ActiveSupport::Notifications for additional flexibility.
|
|
14
|
+
#
|
|
15
|
+
# @param type [Symbol] Event type (e.g., :subscription_created)
|
|
16
|
+
# @param payload [Hash] Event payload from the webhook
|
|
17
|
+
# @return [Event] The published event
|
|
18
|
+
#
|
|
19
|
+
def publish(type, payload)
|
|
20
|
+
event = Event.new(type: type, payload: payload)
|
|
21
|
+
|
|
22
|
+
# Call registered block handlers
|
|
23
|
+
PurchaseKit.config.handlers_for(type).each do |handler|
|
|
24
|
+
handler.call(event)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Also publish via ActiveSupport::Notifications for subscribers
|
|
28
|
+
ActiveSupport::Notifications.instrument("purchasekit.#{type}", event: event)
|
|
29
|
+
|
|
30
|
+
event
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Represents a subscription event from Apple or Google.
|
|
35
|
+
#
|
|
36
|
+
# Events are normalized by the PurchaseKit SaaS before being sent
|
|
37
|
+
# to your application, so you get a consistent interface regardless
|
|
38
|
+
# of which store the purchase came from.
|
|
39
|
+
#
|
|
40
|
+
class Event
|
|
41
|
+
attr_reader :type, :payload
|
|
42
|
+
|
|
43
|
+
def initialize(type:, payload:)
|
|
44
|
+
@type = type.to_sym
|
|
45
|
+
@payload = payload.is_a?(Hash) ? payload.with_indifferent_access : payload
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The customer ID you passed when creating the purchase intent.
|
|
49
|
+
# Use this to look up the user in your database.
|
|
50
|
+
def customer_id
|
|
51
|
+
payload[:customer_id]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The unique subscription ID from the store.
|
|
55
|
+
# Apple: originalTransactionId
|
|
56
|
+
# Google: purchaseToken
|
|
57
|
+
def subscription_id
|
|
58
|
+
payload[:subscription_id]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Which store the purchase came from: "apple" or "google"
|
|
62
|
+
def store
|
|
63
|
+
payload[:store]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The store-specific product ID (e.g., "com.example.pro.annual")
|
|
67
|
+
def store_product_id
|
|
68
|
+
payload[:store_product_id]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The subscription name you configured in PurchaseKit
|
|
72
|
+
def subscription_name
|
|
73
|
+
payload[:subscription_name]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Current status: "active", "canceled", "expired", etc.
|
|
77
|
+
def status
|
|
78
|
+
payload[:status]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# When the current billing period started
|
|
82
|
+
def current_period_start
|
|
83
|
+
parse_time(payload[:current_period_start])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# When the current billing period ends
|
|
87
|
+
def current_period_end
|
|
88
|
+
parse_time(payload[:current_period_end])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# When the subscription will end (for canceled subscriptions)
|
|
92
|
+
def ends_at
|
|
93
|
+
parse_time(payload[:ends_at])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The success path you passed when creating the purchase intent.
|
|
97
|
+
# Use this for redirecting after purchase completion.
|
|
98
|
+
def success_path
|
|
99
|
+
payload[:success_path]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def parse_time(value)
|
|
105
|
+
return nil if value.blank?
|
|
106
|
+
Time.zone.parse(value)
|
|
107
|
+
rescue
|
|
108
|
+
Time.parse(value) rescue nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
module Pay
|
|
3
|
+
class Configuration
|
|
4
|
+
attr_accessor :api_key, :api_url, :app_id, :webhook_secret
|
|
5
|
+
attr_accessor :demo_mode, :demo_products
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@api_url = "https://purchasekit.dev"
|
|
9
|
+
@demo_mode = false
|
|
10
|
+
@demo_products = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def demo_mode?
|
|
14
|
+
@demo_mode
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def base_api_url
|
|
18
|
+
"#{api_url}/api/v1"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def xcode_completion_url(intent_uuid:, host: nil)
|
|
22
|
+
if demo_mode?
|
|
23
|
+
PurchaseKit::Pay::Engine.routes.url_helpers.purchase_intent_completions_url(intent_uuid: intent_uuid, host: host)
|
|
24
|
+
else
|
|
25
|
+
"#{base_api_url}/purchase/intents/#{intent_uuid}/completions"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_writer :config
|
|
32
|
+
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield(config)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|