purchasekit 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b61274d4dffcca3ba325b0f5864a5f8e99d84445e7ea94d9183085699af40ec
4
- data.tar.gz: 66ca2d30a022636d2f25d68eb3c460b7959a921488978ba6a2e9df0d02514045
3
+ metadata.gz: d9deafd906c87c4b35fd61e260cfdb22027f76b92e991bf1e452e883a087c28a
4
+ data.tar.gz: 4cbc9675b2856e555de937efc121b4bb71688decbd596b5dbcabc7e92a7c0fd6
5
5
  SHA512:
6
- metadata.gz: 45e42938f573a5638492caf1dcda2a2a61ae1b8be70286bae67aaa1c93f2ec22027de4b77f5a16387ec561ad2125382d64cda9a7e717b4a07c7fcbfc83aa4100
7
- data.tar.gz: 96849a02d7f7752d2060bcb29b1267dc2ced03e2837c399479a6226a9966a6d9cab5b412c6b0f5bac101526571a0e663f80578f630f6e6ba4ef20a5ca694372f
6
+ metadata.gz: 80ea056afb46318429c244553a4395b4c964f08efacc25f2922a22c864d03f13c0784dbeb32ea9d2df20e658d3414cd14475a8e42c2df2ad5f05cd4ce872852c
7
+ data.tar.gz: 9c194d0edc5bf9ba974c4ab8c5f182bffb9630ee8e0570cf4a094262220cdecb076becea0e95088bf0ec422458774b32719cdefaf7629f80daf6ad2a6cb9da8b
data/README.md CHANGED
@@ -108,6 +108,7 @@ end
108
108
 
109
109
  | Method | Description |
110
110
  |--------|-------------|
111
+ | `event.event_id` | Unique event identifier (for idempotency) |
111
112
  | `event.customer_id` | Your user ID |
112
113
  | `event.subscription_id` | Store's subscription ID |
113
114
  | `event.store` | `"apple"` or `"google"` |
@@ -118,11 +119,17 @@ end
118
119
  | `event.ends_at` | When subscription will end |
119
120
  | `event.success_path` | Redirect path after purchase |
120
121
 
122
+ ### Idempotency
123
+
124
+ Webhooks may be delivered more than once. Write idempotent callbacks using `find_or_create_by` or check `event.event_id` to avoid duplicate side effects.
125
+
121
126
  ## Paywall helper
122
127
 
123
- Build a paywall using the included helper:
128
+ Build a paywall using the included helper. Subscribe to the Turbo Stream for real-time redirects:
124
129
 
125
130
  ```erb
131
+ <%= turbo_stream_from "purchasekit_customer_#{current_user.id}" %>
132
+
126
133
  <%= purchasekit_paywall customer_id: current_user.id, success_path: dashboard_path do |paywall| %>
127
134
  <%= paywall.plan_option product: @annual, selected: true do %>
128
135
  Annual - <%= paywall.price %>/year
@@ -133,8 +140,25 @@ Build a paywall using the included helper:
133
140
  <% end %>
134
141
 
135
142
  <%= paywall.submit "Subscribe" %>
136
- <%= paywall.restore_link %>
137
143
  <% end %>
144
+
145
+ <%= button_to "Restore purchases", restore_purchases_path %>
146
+ ```
147
+
148
+ The restore link checks your server for an active subscription. Implement the endpoint in your app:
149
+
150
+ ```ruby
151
+ # routes.rb
152
+ post "restore_purchases", to: "subscriptions#restore"
153
+
154
+ # subscriptions_controller.rb
155
+ def restore
156
+ if current_user.subscribed?
157
+ redirect_to dashboard_path, notice: "Your subscription is active."
158
+ else
159
+ redirect_to paywall_path, alert: "No active subscription found."
160
+ end
161
+ end
138
162
  ```
139
163
 
140
164
  Products are fetched from the PurchaseKit API:
@@ -1,5 +1,31 @@
1
1
  module PurchaseKit
2
2
  class ApplicationController < ActionController::Base
3
3
  protect_from_forgery with: :exception
4
+
5
+ rescue_from PurchaseKit::NotFoundError, with: :handle_not_found
6
+ rescue_from PurchaseKit::SubscriptionRequiredError, with: :handle_subscription_required
7
+
8
+ private
9
+
10
+ def handle_not_found(exception)
11
+ handle_error("Product not found", :not_found)
12
+ end
13
+
14
+ def handle_subscription_required(exception)
15
+ handle_error("PurchaseKit subscription required", :payment_required)
16
+ end
17
+
18
+ def handle_error(message, status)
19
+ respond_to do |format|
20
+ format.turbo_stream do
21
+ render turbo_stream: turbo_stream.append(
22
+ "purchasekit_paywall",
23
+ partial: "purchase_kit/purchases/error",
24
+ locals: {message: message}
25
+ )
26
+ end
27
+ format.json { render json: {error: message}, status: status }
28
+ end
29
+ end
4
30
  end
5
31
  end
@@ -13,6 +13,9 @@ module PurchaseKit
13
13
  PurchaseKit.queue_pay_webhook(event) if PurchaseKit.pay_enabled?
14
14
 
15
15
  head :ok
16
+ rescue JSON::ParserError => e
17
+ Rails.logger.error "[PurchaseKit] Invalid JSON in webhook: #{e.message}"
18
+ head :bad_request
16
19
  rescue PurchaseKit::SignatureVerificationError => e
17
20
  Rails.logger.error "[PurchaseKit] Webhook signature error: #{e.message}"
18
21
  head :bad_request
@@ -9,14 +9,14 @@ module PurchaseKit::Pay::PaywallHelper
9
9
  builder = PurchaseKit::Pay::PaywallBuilder.new(self, customer)
10
10
 
11
11
  form_data = (options.delete(:data) || {}).merge(
12
- controller: "purchasekit-pay--paywall",
13
- purchasekit_pay__paywall_customer_id_value: customer.id
12
+ controller: "purchasekit--paywall",
13
+ purchasekit__paywall_customer_id_value: customer.id
14
14
  )
15
15
 
16
16
  form_with(url: purchasekit_pay.purchases_path, id: "purchasekit_paywall", data: form_data, **options) do |form|
17
17
  hidden = hidden_field_tag(:customer_id, customer.id)
18
18
  hidden += hidden_field_tag(:success_path, success_path)
19
- hidden += hidden_field_tag(:environment, "sandbox", data: {purchasekit_pay__paywall_target: "environment"})
19
+ hidden += hidden_field_tag(:environment, "sandbox", data: {purchasekit__paywall_target: "environment"})
20
20
  hidden + capture { yield builder }
21
21
  end
22
22
  end
@@ -40,7 +40,7 @@ class PurchaseKit::Pay::PaywallBuilder
40
40
  class: input_class,
41
41
  autocomplete: "off",
42
42
  data: {
43
- purchasekit_pay__paywall_target: "planRadio",
43
+ purchasekit__paywall_target: "planRadio",
44
44
  apple_store_product_id: product.apple_product_id,
45
45
  google_store_product_id: product.google_product_id
46
46
  }
@@ -57,7 +57,7 @@ class PurchaseKit::Pay::PaywallBuilder
57
57
  raise "price must be called within a plan_option block" unless @current_product
58
58
 
59
59
  data = (options.delete(:data) || {}).merge(
60
- purchasekit_pay__paywall_target: "price",
60
+ purchasekit__paywall_target: "price",
61
61
  apple_store_product_id: @current_product.apple_product_id,
62
62
  google_store_product_id: @current_product.google_product_id
63
63
  )
@@ -68,18 +68,11 @@ class PurchaseKit::Pay::PaywallBuilder
68
68
 
69
69
  def submit(text = "Subscribe", **options)
70
70
  data = (options.delete(:data) || {}).merge(
71
- purchasekit_pay__paywall_target: "submitButton",
71
+ purchasekit__paywall_target: "submitButton",
72
72
  turbo_submits_with: text
73
73
  )
74
74
 
75
75
  @template.submit_tag(text, disabled: true, data: data, **options)
76
76
  end
77
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
78
  end
@@ -85,12 +85,5 @@ module PurchaseKit
85
85
  @template.submit_tag(text, disabled: true, data: data, **options)
86
86
  end
87
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
88
  end
96
89
  end
@@ -9,7 +9,22 @@ export default class extends BridgeComponent {
9
9
  this.#fetchPrices()
10
10
  }
11
11
 
12
+ disconnect() {
13
+ if (this.#fallbackTimeoutId) {
14
+ clearTimeout(this.#fallbackTimeoutId)
15
+ }
16
+ }
17
+
12
18
  responseTargetConnected(element) {
19
+ const error = element.dataset.error
20
+
21
+ if (error) {
22
+ element.remove()
23
+ alert(error)
24
+ this.#enableForm()
25
+ return
26
+ }
27
+
13
28
  const correlationId = element.dataset.correlationId
14
29
  const productIds = this.#productIds(element)
15
30
  const relativeUrl = element.dataset.xcodeCompletionUrl
@@ -21,11 +36,6 @@ export default class extends BridgeComponent {
21
36
  this.#triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl, successPath)
22
37
  }
23
38
 
24
- restore(event) {
25
- event.preventDefault()
26
- this.send("restore")
27
- }
28
-
29
39
  #triggerNativePurchase(productIds, correlationId, xcodeCompletionUrl, successPath) {
30
40
  this.send("purchase", { ...productIds, correlationId, xcodeCompletionUrl }, message => {
31
41
  const { status, error } = message.data
@@ -42,16 +52,18 @@ export default class extends BridgeComponent {
42
52
  return
43
53
  }
44
54
 
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(() => {
55
+ // On success, Turbo Stream will broadcast redirect when webhook completes.
56
+ // Fallback: redirect after 30 seconds in case ActionCable isn't connected.
57
+ if (successPath) {
58
+ this.#fallbackTimeoutId = setTimeout(() => {
49
59
  window.Turbo.visit(successPath)
50
- }, 500)
60
+ }, 30000)
51
61
  }
52
62
  })
53
63
  }
54
64
 
65
+ #fallbackTimeoutId = null
66
+
55
67
  #fetchPrices() {
56
68
  const products = this.priceTargets.map(el => this.#productIds(el))
57
69
 
@@ -1,5 +1,5 @@
1
1
  <%= turbo_stream.append "purchasekit_paywall" do %>
2
- <div data-purchasekit-pay--paywall-target="response"
2
+ <div data-purchasekit--paywall-target="response"
3
3
  data-correlation-id="<%= @intent.uuid %>"
4
4
  data-apple-store-product-id="<%= @intent.product.apple_product_id %>"
5
5
  data-google-store-product-id="<%= @intent.product.google_product_id %>"
@@ -0,0 +1,3 @@
1
+ <div data-purchasekit--paywall-target="response"
2
+ data-error="<%= message %>">
3
+ </div>
@@ -18,7 +18,7 @@ module PurchaseKit
18
18
  private
19
19
 
20
20
  def request(method, path, body = nil)
21
- options = {headers: headers}
21
+ options = {headers: headers, timeout: 15}
22
22
  options[:body] = body.to_json if body
23
23
 
24
24
  HTTParty.public_send(method, url(path), options)
@@ -8,9 +8,12 @@ module PurchaseKit
8
8
  ].freeze
9
9
 
10
10
  class << self
11
+ include ActionView::Helpers::TagHelper
12
+
11
13
  # Publish an event to all registered handlers.
12
14
  #
13
15
  # Also publishes via ActiveSupport::Notifications for additional flexibility.
16
+ # Broadcasts redirect for subscription_created when Pay is not handling it.
14
17
  #
15
18
  # @param type [Symbol] Event type (e.g., :subscription_created)
16
19
  # @param payload [Hash] Event payload from the webhook
@@ -27,8 +30,25 @@ module PurchaseKit
27
30
  # Also publish via ActiveSupport::Notifications for subscribers
28
31
  ActiveSupport::Notifications.instrument("purchasekit.#{type}", event: event)
29
32
 
33
+ # Broadcast redirect for new subscriptions (Pay handles its own broadcasts)
34
+ if type == :subscription_created && !PurchaseKit.pay_enabled?
35
+ broadcast_redirect(event)
36
+ end
37
+
30
38
  event
31
39
  end
40
+
41
+ private
42
+
43
+ def broadcast_redirect(event)
44
+ return if event.success_path.blank?
45
+ return unless defined?(Turbo::StreamsChannel)
46
+
47
+ Turbo::StreamsChannel.broadcast_stream_to(
48
+ "purchasekit_customer_#{event.customer_id}",
49
+ content: tag.turbo_stream(action: :redirect, url: event.success_path)
50
+ )
51
+ end
32
52
  end
33
53
 
34
54
  # Represents a subscription event from Apple or Google.
@@ -45,6 +65,12 @@ module PurchaseKit
45
65
  @payload = payload.is_a?(Hash) ? payload.with_indifferent_access : payload
46
66
  end
47
67
 
68
+ # Unique identifier for this event. Use for idempotency checks.
69
+ # Store processed event_ids to prevent duplicate processing.
70
+ def event_id
71
+ payload[:event_id]
72
+ end
73
+
48
74
  # The customer ID you passed when creating the purchase intent.
49
75
  # Use this to look up the user in your database.
50
76
  def customer_id
@@ -1,3 +1,3 @@
1
1
  module PurchaseKit
2
- VERSION = "0.2.4"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/purchasekit.rb CHANGED
@@ -43,10 +43,11 @@ module PurchaseKit
43
43
  end
44
44
 
45
45
  def pay_enabled?
46
- defined?(::Pay)
46
+ defined?(::Pay) && defined?(::Pay::VERSION)
47
47
  end
48
48
 
49
49
  def queue_pay_webhook(event)
50
+ return unless defined?(PurchaseKit::Pay::Webhook)
50
51
  PurchaseKit::Pay::Webhook.queue(event)
51
52
  end
52
53
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: purchasekit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Masilotti
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-01 00:00:00.000000000 Z
11
+ date: 2026-01-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -122,7 +122,6 @@ files:
122
122
  - app/helpers/purchase_kit/pay/paywall_helper.rb
123
123
  - app/helpers/purchase_kit/paywall_helper.rb
124
124
  - app/javascript/controllers/purchasekit/paywall_controller.js
125
- - app/javascript/controllers/purchasekit_pay/paywall_controller.js
126
125
  - app/javascript/purchasekit/turbo_actions.js
127
126
  - app/javascript/purchasekit_pay/turbo_actions.js
128
127
  - app/models/pay/purchasekit/charge.rb
@@ -130,6 +129,7 @@ files:
130
129
  - app/models/pay/purchasekit/subscription.rb
131
130
  - app/views/purchase_kit/pay/purchases/_subscription_required.html.erb
132
131
  - app/views/purchase_kit/pay/purchases/create.turbo_stream.erb
132
+ - app/views/purchase_kit/purchases/_error.html.erb
133
133
  - app/views/purchase_kit/purchases/_intent.html.erb
134
134
  - config/importmap.rb
135
135
  - config/routes.rb
@@ -1,112 +0,0 @@
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
- }