purchasekit 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +31 -0
  3. data/README.md +165 -0
  4. data/Rakefile +10 -0
  5. data/app/controllers/purchase_kit/application_controller.rb +5 -0
  6. data/app/controllers/purchase_kit/pay/application_controller.rb +2 -0
  7. data/app/controllers/purchase_kit/pay/purchase/completions_controller.rb +32 -0
  8. data/app/controllers/purchase_kit/pay/purchases_controller.rb +34 -0
  9. data/app/controllers/purchase_kit/pay/webhooks_controller.rb +35 -0
  10. data/app/controllers/purchase_kit/purchase/completions_controller.rb +39 -0
  11. data/app/controllers/purchase_kit/purchases_controller.rb +37 -0
  12. data/app/controllers/purchase_kit/webhooks_controller.rb +42 -0
  13. data/app/helpers/purchase_kit/pay/paywall_helper.rb +85 -0
  14. data/app/helpers/purchase_kit/paywall_helper.rb +96 -0
  15. data/app/javascript/controllers/purchasekit/paywall_controller.js +121 -0
  16. data/app/javascript/controllers/purchasekit_pay/paywall_controller.js +112 -0
  17. data/app/javascript/purchasekit/turbo_actions.js +4 -0
  18. data/app/javascript/purchasekit_pay/turbo_actions.js +4 -0
  19. data/app/models/pay/purchasekit/charge.rb +9 -0
  20. data/app/models/pay/purchasekit/customer.rb +22 -0
  21. data/app/models/pay/purchasekit/subscription.rb +33 -0
  22. data/app/views/purchase_kit/pay/purchases/_subscription_required.html.erb +3 -0
  23. data/app/views/purchase_kit/pay/purchases/create.turbo_stream.erb +8 -0
  24. data/app/views/purchase_kit/purchases/_intent.html.erb +7 -0
  25. data/config/importmap.rb +3 -0
  26. data/config/routes.rb +7 -0
  27. data/lib/pay/purchasekit.rb +25 -0
  28. data/lib/purchasekit/api_client.rb +43 -0
  29. data/lib/purchasekit/configuration.rb +51 -0
  30. data/lib/purchasekit/engine.rb +38 -0
  31. data/lib/purchasekit/error.rb +19 -0
  32. data/lib/purchasekit/events.rb +112 -0
  33. data/lib/purchasekit/pay/configuration.rb +42 -0
  34. data/lib/purchasekit/pay/engine.rb +35 -0
  35. data/lib/purchasekit/pay/error.rb +12 -0
  36. data/lib/purchasekit/pay/version.rb +5 -0
  37. data/lib/purchasekit/pay/webhook.rb +36 -0
  38. data/lib/purchasekit/pay/webhooks/base.rb +25 -0
  39. data/lib/purchasekit/pay/webhooks/subscription_canceled.rb +11 -0
  40. data/lib/purchasekit/pay/webhooks/subscription_created.rb +39 -0
  41. data/lib/purchasekit/pay/webhooks/subscription_expired.rb +11 -0
  42. data/lib/purchasekit/pay/webhooks/subscription_updated.rb +31 -0
  43. data/lib/purchasekit/pay/webhooks.rb +11 -0
  44. data/lib/purchasekit/product/demo.rb +23 -0
  45. data/lib/purchasekit/product/remote.rb +26 -0
  46. data/lib/purchasekit/product.rb +52 -0
  47. data/lib/purchasekit/purchase/intent/demo.rb +59 -0
  48. data/lib/purchasekit/purchase/intent/remote.rb +37 -0
  49. data/lib/purchasekit/purchase/intent.rb +55 -0
  50. data/lib/purchasekit/version.rb +3 -0
  51. data/lib/purchasekit/webhook_signature.rb +60 -0
  52. data/lib/purchasekit-pay.rb +15 -0
  53. data/lib/purchasekit.rb +60 -0
  54. metadata +189 -0
@@ -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,4 @@
1
+ // Custom Turbo Stream action for redirects after subscription events.
2
+ Turbo.StreamActions.redirect = function() {
3
+ Turbo.visit(this.getAttribute("url"), {action: "replace"})
4
+ }
@@ -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,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>
@@ -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,7 @@
1
+ PurchaseKit::Engine.routes.draw do
2
+ resource :webhooks, only: :create
3
+ resources :purchases, only: :create
4
+
5
+ # Demo mode only - Xcode StoreKit testing completion endpoint
6
+ post "purchase/completions/:id", to: "purchase/completions#create"
7
+ 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,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