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,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,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,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,31 @@
|
|
|
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
|
+
broadcast_redirect(event) if event["success_path"].present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def broadcast_redirect(event)
|
|
22
|
+
customer = ::Pay::Customer.find(event["customer_id"])
|
|
23
|
+
Turbo::StreamsChannel.broadcast_stream_to(
|
|
24
|
+
dom_id(customer),
|
|
25
|
+
content: turbo_stream_action_tag(:redirect, url: event["success_path"])
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
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,23 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
class Product
|
|
3
|
+
# Demo implementation of Product for local development.
|
|
4
|
+
#
|
|
5
|
+
# Reads product data from PurchaseKit.config.demo_products instead
|
|
6
|
+
# of making API calls. Designed for use with Xcode's StoreKit testing.
|
|
7
|
+
#
|
|
8
|
+
class Demo
|
|
9
|
+
class << self
|
|
10
|
+
def find(id)
|
|
11
|
+
product_data = PurchaseKit.config.demo_products[id]
|
|
12
|
+
raise PurchaseKit::NotFoundError, "Product not found: #{id}" unless product_data
|
|
13
|
+
|
|
14
|
+
Product.new(
|
|
15
|
+
id: id,
|
|
16
|
+
apple_product_id: product_data[:apple_product_id],
|
|
17
|
+
google_product_id: product_data[:google_product_id]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
class Product
|
|
3
|
+
# Production implementation of Product that fetches from PurchaseKit API.
|
|
4
|
+
#
|
|
5
|
+
class Remote
|
|
6
|
+
class << self
|
|
7
|
+
def find(id)
|
|
8
|
+
response = ApiClient.new.get("/products/#{id}")
|
|
9
|
+
|
|
10
|
+
case response.code
|
|
11
|
+
when 200
|
|
12
|
+
Product.new(
|
|
13
|
+
id: response["id"],
|
|
14
|
+
apple_product_id: response["apple_product_id"],
|
|
15
|
+
google_product_id: response["google_product_id"]
|
|
16
|
+
)
|
|
17
|
+
when 404
|
|
18
|
+
raise PurchaseKit::NotFoundError, "Product not found: #{id}"
|
|
19
|
+
else
|
|
20
|
+
raise PurchaseKit::Error, "API error: #{response.code} #{response.message}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
# Represents a product configured in the PurchaseKit dashboard.
|
|
3
|
+
#
|
|
4
|
+
# Products contain the store-specific product IDs for Apple and Google.
|
|
5
|
+
# Display text (name, description, price) should be fetched from the
|
|
6
|
+
# stores at runtime or defined in your views for i18n support.
|
|
7
|
+
#
|
|
8
|
+
# Example:
|
|
9
|
+
# product = PurchaseKit::Product.find("prod_XXXXX")
|
|
10
|
+
# product.apple_product_id # => "com.example.pro.annual"
|
|
11
|
+
# product.google_product_id # => "pro_annual"
|
|
12
|
+
#
|
|
13
|
+
class Product
|
|
14
|
+
attr_reader :id, :apple_product_id, :google_product_id
|
|
15
|
+
|
|
16
|
+
def initialize(id:, apple_product_id: nil, google_product_id: nil)
|
|
17
|
+
@id = id
|
|
18
|
+
@apple_product_id = apple_product_id
|
|
19
|
+
@google_product_id = google_product_id
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Get the store-specific product ID for a platform.
|
|
23
|
+
#
|
|
24
|
+
# @param platform [Symbol] :apple or :google
|
|
25
|
+
# @return [String] The store product ID
|
|
26
|
+
#
|
|
27
|
+
def store_product_id(platform:)
|
|
28
|
+
case platform
|
|
29
|
+
when :apple then apple_product_id
|
|
30
|
+
when :google then google_product_id
|
|
31
|
+
else raise ArgumentError, "Unknown platform: #{platform}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Find a product by ID.
|
|
36
|
+
#
|
|
37
|
+
# In demo mode, reads from configured demo_products.
|
|
38
|
+
# In production, fetches from the PurchaseKit API.
|
|
39
|
+
#
|
|
40
|
+
# @param id [String] The product ID (e.g., "prod_XXXXX" or a demo key)
|
|
41
|
+
# @return [Product]
|
|
42
|
+
# @raise [NotFoundError] if product doesn't exist
|
|
43
|
+
#
|
|
44
|
+
def self.find(id)
|
|
45
|
+
if PurchaseKit.config.demo_mode?
|
|
46
|
+
Demo.find(id)
|
|
47
|
+
else
|
|
48
|
+
Remote.find(id)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module PurchaseKit
|
|
4
|
+
module Purchase
|
|
5
|
+
class Intent
|
|
6
|
+
# Demo implementation of Intent for local development.
|
|
7
|
+
#
|
|
8
|
+
# Stores intents in memory instead of making API calls.
|
|
9
|
+
# Designed for use with Xcode's StoreKit testing.
|
|
10
|
+
#
|
|
11
|
+
class Demo < Intent
|
|
12
|
+
attr_reader :customer_id
|
|
13
|
+
|
|
14
|
+
def initialize(id:, uuid:, product:, customer_id:, success_path:)
|
|
15
|
+
super(id: id, uuid: uuid, product: product, success_path: success_path)
|
|
16
|
+
@customer_id = customer_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# URL for Xcode StoreKit testing to simulate purchase completion
|
|
20
|
+
def xcode_completion_url
|
|
21
|
+
"/purchasekit/purchase/completions/#{uuid}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def find(uuid)
|
|
26
|
+
intent = store[uuid]
|
|
27
|
+
raise PurchaseKit::NotFoundError, "Intent not found: #{uuid}" unless intent
|
|
28
|
+
intent
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create(product_id:, customer_id:, success_path: nil, environment: nil)
|
|
32
|
+
product = Product.find(product_id)
|
|
33
|
+
uuid = SecureRandom.uuid
|
|
34
|
+
id = "intent_#{SecureRandom.hex(8)}"
|
|
35
|
+
|
|
36
|
+
intent = new(
|
|
37
|
+
id: id,
|
|
38
|
+
uuid: uuid,
|
|
39
|
+
product: product,
|
|
40
|
+
customer_id: customer_id,
|
|
41
|
+
success_path: success_path
|
|
42
|
+
)
|
|
43
|
+
store[uuid] = intent
|
|
44
|
+
intent
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def store
|
|
48
|
+
@store ||= {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Clear the in-memory store (useful for tests)
|
|
52
|
+
def clear_store!
|
|
53
|
+
@store = {}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
module Purchase
|
|
3
|
+
class Intent
|
|
4
|
+
# Production implementation of Intent that creates via PurchaseKit API.
|
|
5
|
+
#
|
|
6
|
+
class Remote < Intent
|
|
7
|
+
class << self
|
|
8
|
+
def create(product_id:, customer_id:, success_path: nil, environment: nil)
|
|
9
|
+
response = ApiClient.new.post("/purchase/intents", {
|
|
10
|
+
product_id: product_id,
|
|
11
|
+
customer_id: customer_id,
|
|
12
|
+
success_path: success_path,
|
|
13
|
+
environment: environment
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
case response.code
|
|
17
|
+
when 201
|
|
18
|
+
product_data = response["product"]
|
|
19
|
+
product = Product.new(
|
|
20
|
+
id: product_data["id"],
|
|
21
|
+
apple_product_id: product_data["apple_product_id"],
|
|
22
|
+
google_product_id: product_data["google_product_id"]
|
|
23
|
+
)
|
|
24
|
+
new(id: response["id"], uuid: response["uuid"], product: product, success_path: success_path)
|
|
25
|
+
when 402
|
|
26
|
+
raise PurchaseKit::SubscriptionRequiredError, response["error"] || "Subscription required for production purchases"
|
|
27
|
+
when 404
|
|
28
|
+
raise PurchaseKit::NotFoundError, "App or product not found"
|
|
29
|
+
else
|
|
30
|
+
raise PurchaseKit::Error, "API error: #{response.code} #{response.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module PurchaseKit
|
|
2
|
+
module Purchase
|
|
3
|
+
# Represents a purchase intent - the record created before a user
|
|
4
|
+
# initiates an in-app purchase.
|
|
5
|
+
#
|
|
6
|
+
# The intent contains a UUID that gets passed to the store as the
|
|
7
|
+
# appAccountToken (Apple) or obfuscatedAccountId (Google). This allows
|
|
8
|
+
# PurchaseKit to correlate the store's webhook with your user.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# intent = PurchaseKit::Purchase::Intent.create(
|
|
12
|
+
# product_id: "prod_XXXXX",
|
|
13
|
+
# customer_id: current_user.payment_processor.id,
|
|
14
|
+
# success_path: "/dashboard",
|
|
15
|
+
# environment: "sandbox"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# intent.uuid # => Pass to native app for store purchase
|
|
19
|
+
# intent.product # => Contains store product IDs
|
|
20
|
+
#
|
|
21
|
+
class Intent
|
|
22
|
+
attr_reader :id, :uuid, :product, :success_path
|
|
23
|
+
|
|
24
|
+
def initialize(id:, uuid:, product:, success_path: nil)
|
|
25
|
+
@id = id
|
|
26
|
+
@uuid = uuid
|
|
27
|
+
@product = product
|
|
28
|
+
@success_path = success_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Override in subclasses if needed
|
|
32
|
+
def xcode_completion_url
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create a new purchase intent.
|
|
37
|
+
#
|
|
38
|
+
# @param product_id [String] The PurchaseKit product ID
|
|
39
|
+
# @param customer_id [Integer, String] Your customer/user ID (will be included in webhooks)
|
|
40
|
+
# @param success_path [String] Where to redirect after successful purchase
|
|
41
|
+
# @param environment [String] "sandbox" or "production"
|
|
42
|
+
# @return [Intent]
|
|
43
|
+
# @raise [NotFoundError] if product doesn't exist
|
|
44
|
+
# @raise [SubscriptionRequiredError] if PurchaseKit subscription needed for production
|
|
45
|
+
#
|
|
46
|
+
def self.create(product_id:, customer_id:, success_path: nil, environment: nil)
|
|
47
|
+
if PurchaseKit.config.demo_mode?
|
|
48
|
+
Demo.create(product_id:, customer_id:, success_path:)
|
|
49
|
+
else
|
|
50
|
+
Remote.create(product_id:, customer_id:, success_path:, environment:)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module PurchaseKit
|
|
5
|
+
# Verifies HMAC-SHA256 signatures on incoming webhooks.
|
|
6
|
+
#
|
|
7
|
+
# The PurchaseKit SaaS signs all webhook payloads with your webhook secret.
|
|
8
|
+
# This class verifies those signatures to ensure webhooks are authentic.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# PurchaseKit::WebhookSignature.verify!(
|
|
12
|
+
# payload: request.raw_post,
|
|
13
|
+
# signature: request.headers["X-PurchaseKit-Signature"],
|
|
14
|
+
# secret: PurchaseKit.config.webhook_secret
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
class WebhookSignature
|
|
18
|
+
attr_reader :payload, :signature, :secret
|
|
19
|
+
|
|
20
|
+
def initialize(payload:, signature:, secret:)
|
|
21
|
+
@payload = payload
|
|
22
|
+
@signature = signature
|
|
23
|
+
@secret = secret
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Verify the signature. Raises SignatureVerificationError if invalid.
|
|
27
|
+
def verify!
|
|
28
|
+
if secret.blank?
|
|
29
|
+
raise SignatureVerificationError, "webhook_secret must be configured"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if signature.blank?
|
|
33
|
+
raise SignatureVerificationError, "Missing signature"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
|
|
37
|
+
unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
|
38
|
+
raise SignatureVerificationError, "Invalid signature"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Verify the signature and return the parsed JSON payload.
|
|
45
|
+
def verified_payload
|
|
46
|
+
verify!
|
|
47
|
+
JSON.parse(payload, symbolize_names: true)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def verify!(payload:, signature:, secret:)
|
|
52
|
+
new(payload: payload, signature: signature, secret: secret).verify!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verified_payload(payload:, signature:, secret:)
|
|
56
|
+
new(payload: payload, signature: signature, secret: secret).verified_payload
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
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/product/demo"
|
|
11
|
+
require "purchasekit/product/remote"
|
|
12
|
+
require "purchasekit/purchase/intent"
|
|
13
|
+
require "purchasekit/purchase/intent/demo"
|
|
14
|
+
require "purchasekit/purchase/intent/remote"
|
|
15
|
+
require "pay/purchasekit"
|
data/lib/purchasekit.rb
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# PurchaseKit - In-app purchase webhooks for Rails
|
|
2
|
+
#
|
|
3
|
+
# This gem handles:
|
|
4
|
+
# - Configuration and API client
|
|
5
|
+
# - Product and purchase intent management
|
|
6
|
+
# - Webhook signature verification
|
|
7
|
+
# - Event callbacks for subscription lifecycle
|
|
8
|
+
# - Rails engine with webhooks controller and paywall helpers
|
|
9
|
+
# - Pay gem integration (auto-detected when Pay is present)
|
|
10
|
+
|
|
11
|
+
require "active_support"
|
|
12
|
+
require "active_support/core_ext/object/blank"
|
|
13
|
+
require "active_support/core_ext/hash/indifferent_access"
|
|
14
|
+
require "active_support/notifications"
|
|
15
|
+
require "active_support/security_utils"
|
|
16
|
+
|
|
17
|
+
require "purchasekit/version"
|
|
18
|
+
require "purchasekit/configuration"
|
|
19
|
+
require "purchasekit/error"
|
|
20
|
+
require "purchasekit/events"
|
|
21
|
+
require "purchasekit/webhook_signature"
|
|
22
|
+
require "purchasekit/api_client"
|
|
23
|
+
require "purchasekit/product"
|
|
24
|
+
require "purchasekit/product/demo"
|
|
25
|
+
require "purchasekit/product/remote"
|
|
26
|
+
require "purchasekit/purchase/intent"
|
|
27
|
+
require "purchasekit/purchase/intent/demo"
|
|
28
|
+
require "purchasekit/purchase/intent/remote"
|
|
29
|
+
require "purchasekit/engine"
|
|
30
|
+
|
|
31
|
+
module PurchaseKit
|
|
32
|
+
class << self
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield(config)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset_config!
|
|
42
|
+
@config = Configuration.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pay_enabled?
|
|
46
|
+
defined?(::Pay)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def queue_pay_webhook(event)
|
|
50
|
+
PurchaseKit::Pay::Webhook.queue(event)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Load Pay integration if Pay gem is available
|
|
56
|
+
if PurchaseKit.pay_enabled?
|
|
57
|
+
require "purchasekit/pay/webhooks"
|
|
58
|
+
require "purchasekit/pay/webhook"
|
|
59
|
+
require "pay/purchasekit"
|
|
60
|
+
end
|