tillbell 0.1.1 → 0.2.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: 72c52a2e210cb60ee2fa3173935075173b3c90a1e30574c7d0b381210c8374f2
4
- data.tar.gz: d7d9d6f30c25c140ee124e8de0f567995a57b31a8a211ca76905859f6c78d875
3
+ metadata.gz: 0a61824e3e025f8e1614137ca1139ed6d76aff9d201a40960704824618926e05
4
+ data.tar.gz: ac1bdc03f6ef383c121b02ff4b03c8d725f65e3b8184a3c058c65e0aa5c24a12
5
5
  SHA512:
6
- metadata.gz: 6b9e6e9f84d106ed21019f130acc49913a6dea88014d0c3eaa4b5bcb777d957eafe2173b5ea7892bba9f7eacfbe4421415eb5787c0b47eb696db0fcc2c0d0681
7
- data.tar.gz: 9f8314103d9234e46279cd3625fe2603c340e42889435d0adfbd6ef245eca82b7dd354cef6513a78aa3f3363dbf06586beeb9e1f28778a948a364c17450a8f26
6
+ metadata.gz: 0ca823bfe9a3749f16d0af8103f580cc19cc547eb3c1f9ff1c6f9a76d81de686342bfc3b1656cb245ad3fdf4d37a5febe3a4b82526aa8ba7fe1f4e06febae0a0
7
+ data.tar.gz: 833739c0d7431f22a909c83e3b48e19365163554165d3c5b73b2ef538d8fd96cedd753de6311697f260feae454bb1b04e171c7f8fd524be83cb0ff8272625593
data/CHANGELOG.md CHANGED
@@ -6,6 +6,38 @@ All notable changes to this project are documented in this file. The format foll
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - `Tillbell.native?(user_agent)` and the `tillbell_native?` view helper: tell whether the
14
+ app showing the page registered the `tillbell` component, to choose between the paywall
15
+ and another way to pay.
16
+ - Details of a `Pay::GooglePlay::Subscription` read from Google's last answer:
17
+ `price_amount` and `price_currency`, `replacement?`, `linked_purchase_token`, `replaced?`,
18
+ `plan_change_pending?`, `deferred_product_id` and `manage_url`.
19
+ - `Tillbell::GooglePlay.manage_url(product_id = nil)`: the Play Store page of a
20
+ subscription, for web pages and emails.
21
+ - README: detecting the native app, plan changes (replacement modes, `DEFERRED`
22
+ downgrades), subscription details, several processors per customer, closing an account,
23
+ and events during a plan change.
24
+
25
+ ### Changed
26
+
27
+ - A replaced subscription remembers the purchase token that replaced it (`data["replaced_by"]`)
28
+ and keeps its ended state: a later read of the old token no longer brings it back.
29
+ - `cancel` decides whether to ask Google from its subscription state (canceled or expired)
30
+ instead of `auto_renewing`, which is false while a deferred plan change waits for the renewal
31
+ though the subscription will renew.
32
+
33
+ ### Fixed
34
+
35
+ - `cancel` (and `cancel_now!`) on a subscription Google answers 410 for (a purchase token
36
+ expired for more than 60 days) no longer raises: it made closing an account or deleting an
37
+ owner fail for a customer who left long ago, including for a row still stored as active
38
+ because a notification was missed. The subscription is recorded as ended and
39
+ `subscription_expired` is emitted once. Other errors still raise.
40
+
9
41
  ## [0.1.1] - 2026-10-30
10
42
 
11
43
  ### Changed
data/README.md CHANGED
@@ -211,16 +211,46 @@ Prices always come from Google Play, never from your app.
211
211
 
212
212
  **Without the native component** (a browser, or an old app version), the Stimulus controller does not connect. The buttons stay disabled, the fallback stays visible, and no purchase can start.
213
213
 
214
+ **Detecting the native app.** Hotwire Native lists the components an app registered in its user agent (`bridge-components: [haptic tillbell]`). To choose between the paywall and another way to pay, ask Tillbell instead of parsing it yourself:
215
+
216
+ ```erb
217
+ <% if tillbell_native? %>
218
+ <%= tillbell_paywall success_url: dashboard_path do |paywall| %> ... <% end %>
219
+ <% else %>
220
+ <%= link_to "Subscribe on the web", checkout_path %>
221
+ <% end %>
222
+ ```
223
+
224
+ In a controller, use `Tillbell.native?(request.user_agent)`. It only picks which page to show: a purchase is always verified with Google, so a forged user agent gains nothing. It answers `true` for any native app (Android today, iOS later) that registered the component, and `false` for browsers and for app versions without it.
225
+
214
226
  **Plan changes.** Pass the current subscription and Google's [replacement mode](https://developer.android.com/google/play/billing/subscriptions#replacement-modes). Tillbell passes the mode through and never decides proration:
215
227
 
216
228
  ```erb
217
- <%= paywall.plan_option product_id: "family", base_plan_id: "monthly",
218
- replacing: current_user.pay_subscriptions.active.first,
219
- replacement_mode: "CHARGE_PRORATED_PRICE" do %>
220
- Switch to Family, <%= paywall.price %>
229
+ <%= tillbell_paywall success_url: settings_path do |paywall| %>
230
+ <%= paywall.plan_option product_id: "family", base_plan_id: "monthly", checked: true,
231
+ replacing: current_user.pay_subscriptions.google_play.active.first, # the current Pay::GooglePlay::Subscription
232
+ replacement_mode: "CHARGE_PRORATED_PRICE" do %>
233
+ Switch to Family, <%= paywall.price %>
234
+ <% end %>
235
+ <%= paywall.submit "Switch to Family" %>
236
+ <%= paywall.status %>
221
237
  <% end %>
222
238
  ```
223
239
 
240
+ | Replacement mode | Use it for | Effect |
241
+ |---|---|---|
242
+ | `CHARGE_PRORATED_PRICE` | An upgrade to a more expensive plan (upgrades only) | Takes effect now. The customer pays the price difference for the rest of the period, and the billing date does not change. |
243
+ | `WITH_TIME_PRORATION` | An upgrade (Google's default) | Takes effect now. The value of the unused time is credited by pushing the next billing date forward. |
244
+ | `WITHOUT_PRORATION` | An upgrade that keeps a free period | Takes effect now. The new price applies from the next renewal. |
245
+ | `CHARGE_FULL_PRICE` | Switching to a longer billing period | Takes effect now. The customer is charged the full price of the new plan, and the remaining value of the old one is credited. |
246
+ | `DEFERRED` | A downgrade to a cheaper plan | Takes effect at the next renewal: the customer keeps what they paid for until then. |
247
+
248
+ Google recommends `CHARGE_PRORATED_PRICE` for an upgrade and `DEFERRED` for a downgrade. The server picks the mode, never the app, so a customer cannot choose a cheaper way to upgrade.
249
+
250
+ A purchase that replaces another (a plan change, or a customer who signs up again for a subscription that was canceled but had not lapsed) gets a new purchase token that points to the old one. Tillbell stores the new subscription, ends the old one, and emits `subscription_created` then `subscription_expired` (see [Events](#events)).
251
+
252
+ A `DEFERRED` change waits for the renewal. Google reports it on the **new** purchase: one line item for the entitlement that is about to end, carrying the product the customer is moving to, and one for the entitlement that follows. Until the renewal the new subscription keeps the current product as `processor_plan`, and `subscription.plan_change_pending?` and `subscription.deferred_product_id` tell where it is going, so the page can say "Switching to Solo on March 3" (`current_period_end`). After the renewal `processor_plan` is the new product. Only an active subscription that is still renewing can change plan: hide the option for a canceled (`on_grace_period?`) or held (`on_hold?`) one.
253
+
224
254
  **JavaScript events.** The controller dispatches events on the paywall element:
225
255
 
226
256
  - `tillbell:verified`: the purchase grants access. Call `event.preventDefault()` to stay on the page instead of visiting `success_url`.
@@ -252,9 +282,30 @@ A `Pay::GooglePlay::Subscription` stores:
252
282
  - `data`: `base_plan_id`, `subscription_state`, `auto_renewing` and `revoked_at`;
253
283
  - `object`: Google's last answer.
254
284
 
285
+ **Details from Google.** These read Google's last answer, so they cost no API call:
286
+
287
+ | Method | Returns |
288
+ |---|---|
289
+ | `price_amount`, `price_currency` | The price of one renewal as a `BigDecimal` in the units of its currency (`12.99`, or `1200` yen) and its ISO currency (`"EUR"`), as Google bills it in the customer's country. For analytics and conversion events (e.g. a Meta or Google Ads purchase, which take a decimal value); show prices to customers from Google Play, as the paywall does. It is the price of the entitlement in force. `nil` when Google gave none. |
290
+ | `replacement?`, `linked_purchase_token` | Whether this purchase replaces an earlier one, and the token of that one. Google sets it for a plan change, for a customer who signs up again for a subscription that was canceled but had not lapsed, and for prepaid plans converted to or topped up as auto-renewing. Compare `processor_plan` with the replaced subscription's to tell a plan change. |
291
+ | `replaced?` | Whether a newer purchase replaced this subscription. Set when the replacement is recorded, so it is already true inside the `subscription_expired` callback; use it to tell a replaced subscription from a customer who left (see [Events](#events)). Subscriptions replaced before this was added are not flagged. |
292
+ | `plan_change_pending?`, `deferred_product_id` | A `DEFERRED` plan change waiting for the next renewal, and the product it goes to. |
293
+ | `manage_url` | The Play Store page where the customer manages this subscription (update the payment method, cancel, resubscribe). Same page as `paywall.manage`, for web pages and emails. `Tillbell::GooglePlay.manage_url(product_id)` gives it for any product, and without one the list of all subscriptions. |
294
+ | `in_grace_period?`, `on_hold?`, `revoked?` | The Google states that Pay statuses merge. |
295
+
296
+ A customer can have a Stripe subscription and a Google Play one (a web customer who later subscribes in the app, or the reverse). `Pay::GooglePlay::Customer` is never the owner's default payment processor, so `owner.payment_processor` does not see Google Play subscriptions: look at `owner.pay_subscriptions`, or at the Google Play customer (`owner.pay_customers.google_play.first`), to decide who has access.
297
+
255
298
  Pay only knows its own processors, so Tillbell adds `google_play?` to customers and subscriptions, and a `google_play` scope next to Pay's `stripe` one (`Pay::Subscription.google_play`).
256
299
 
257
- `subscription.cancel` stops the renewal through the Google Play Developer API. `swap`, `pause` and `resume` raise `Tillbell::Error`: plan changes go through the paywall, and the rest happens in the Play Store.
300
+ `subscription.cancel` stops the renewal through the Google Play Developer API; the customer keeps access until the end of the paid period. Google can only end a subscription immediately by refunding it, which Tillbell never does on its own, so `cancel_now!` (which Pay calls when an owner is destroyed) also just stops the renewal. Google answers 410 for a purchase token that expired more than 60 days ago: a subscription still stored as active (a missed notification) is then recorded as ended, with a `subscription_expired` event, instead of failing the deletion of its owner. Any other error is raised, a 404 from a wrong package name for example: the subscription may still be billing. A subscription ended or already canceled is left alone, and one that waits for a deferred plan change is cancelled like any other: whether it is renewing comes from Google's subscription state, not from the entitlement about to end. `swap`, `pause` and `resume` raise `Tillbell::Error`: plan changes go through the paywall, and the rest happens in the Play Store.
301
+
302
+ **Closing or deleting an account.** Cancel what still bills before you close the account, whatever the processor, and before changing anything in your database: an error raised after the account is closed leaves the subscription billing a closed account. Pay's own hook when an owner is destroyed only cancels `active` subscriptions. Google keeps retrying a held payment (`past_due`) and resumes a paused subscription by itself, so include both:
303
+
304
+ ```ruby
305
+ user.pay_subscriptions.where(status: %w[active past_due paused]).reject(&:ended?).each(&:cancel_now!)
306
+ ```
307
+
308
+ Google can fail (API error, revoked service account key), so rescue `Pay::Error` (which includes `Tillbell::Error`, raised when the package name or the service account is not configured), `Google::Apis::Error` and `Signet::AuthorizationError` next to your other processors' errors, and tell the customer to try again.
258
309
 
259
310
  ## Events
260
311
 
@@ -269,6 +320,19 @@ Tillbell.configure do |config|
269
320
  end
270
321
  ```
271
322
 
323
+ A plan change fires `subscription_created` for the new purchase (`replacement?` is true) and `subscription_expired` for the old one (`replaced?` is true). Renewals fire `subscription_updated` too, so filter on what you care about, for example in a Slack or analytics subscriber:
324
+
325
+ ```ruby
326
+ Tillbell.on(:subscription_updated) do |subscription|
327
+ changes = subscription.saved_changes.keys
328
+ notify("Payment on hold") if changes.include?("status") && subscription.on_hold?
329
+ end
330
+
331
+ Tillbell.on(:subscription_expired) do |subscription|
332
+ notify("Subscription ended") unless subscription.replaced? # not a plan change
333
+ end
334
+ ```
335
+
272
336
  Events go through Pay's webhook delegator, so `Pay::Webhooks.delegator.subscribe "tillbell.subscription_created", MyHandler.new` works too. An event fires once per actual change, after the subscription is saved. An error raised in a callback is reported to `Rails.error` and does not undo the change.
273
337
 
274
338
  ## Google Play setup
@@ -1,6 +1,18 @@
1
1
  module Tillbell
2
2
  # View helper rendering the Google Play paywall, available in every view of the host app.
3
3
  module PaywallHelper
4
+ # True when the app showing the page registered the +tillbell+ bridge component, i.e.
5
+ # when the paywall can start a purchase. Same check as +Tillbell.native?+.
6
+ #
7
+ # <% if tillbell_native? %>
8
+ # <%= tillbell_paywall ... %>
9
+ # <% else %>
10
+ # <%= link_to "Subscribe on the web", checkout_path %>
11
+ # <% end %>
12
+ def tillbell_native?
13
+ Tillbell.native?(request.user_agent)
14
+ end
15
+
4
16
  # Renders an unstyled paywall driven by the +tillbell+ Stimulus controller, which
5
17
  # talks to the +tillbell+ Hotwire Native bridge component.
6
18
  #
@@ -40,7 +40,7 @@ module Pay
40
40
  # Google states after which the subscription ends at the expiry time of its period.
41
41
  ENDING_STATES = %w[SUBSCRIPTION_STATE_CANCELED SUBSCRIPTION_STATE_EXPIRED].freeze
42
42
 
43
- store_accessor :data, :subscription_state, :base_plan_id, :auto_renewing, :revoked_at
43
+ store_accessor :data, :subscription_state, :base_plan_id, :auto_renewing, :revoked_at, :replaced_by
44
44
 
45
45
  class << self
46
46
  # Reads a purchase from Google (unless +object+ is given), saves it and emits the
@@ -65,7 +65,7 @@ module Pay
65
65
  subscription, event = save_from_google(pay_customer, purchase_token, attributes_from(object, revoked: revoked))
66
66
  Tillbell.instrument(event, subscription) if event
67
67
 
68
- retire(object[:linked_purchase_token])
68
+ retire(object[:linked_purchase_token], replaced_by: purchase_token)
69
69
  acknowledge(subscription, object)
70
70
  subscription
71
71
  end
@@ -105,8 +105,10 @@ module Pay
105
105
  subscription = pay_customer.subscriptions.lock.find_or_initialize_by(processor_id: purchase_token)
106
106
  # A refund is final: later reads report a normal expiry (or, if Google is
107
107
  # lagging, an active subscription), and a duplicated refund notification
108
- # must not emit a second event.
109
- subscription.assign_attributes(subscription.revoked? ? attributes.except(:status, :ends_at, :revoked_at) : attributes)
108
+ # must not emit a second event. So is a replacement: the new purchase carries
109
+ # the access, and a later read of the old token (a deferred plan change may
110
+ # still report it as active until the renewal) must not bring it back.
111
+ subscription.assign_attributes(subscription.revoked? || subscription.replaced? ? attributes.except(:status, :ends_at, :revoked_at) : attributes)
110
112
 
111
113
  event = subscription.event_for_changes
112
114
  subscription.save!
@@ -119,13 +121,15 @@ module Pay
119
121
  end
120
122
  end
121
123
 
122
- # A plan change creates a new purchase token that points to the old one. The old
123
- # subscription ends now; the new one carries the access.
124
- def retire(linked_purchase_token)
124
+ # A purchase that replaces another (a plan change, or a customer who signs up again
125
+ # for a canceled subscription) gets a new purchase token that points to the old one.
126
+ # The old subscription ends now, remembers the token that replaced it, and the new
127
+ # one carries the access.
128
+ def retire(linked_purchase_token, replaced_by:)
125
129
  return if linked_purchase_token.blank?
126
130
 
127
131
  where(processor_id: linked_purchase_token).where.not(status: "canceled").find_each do |subscription|
128
- subscription.update!(status: "canceled", ends_at: Time.current)
132
+ subscription.update!(status: "canceled", ends_at: Time.current, replaced_by: replaced_by)
129
133
  Tillbell.instrument(:subscription_expired, subscription)
130
134
  end
131
135
  end
@@ -169,13 +173,87 @@ module Pay
169
173
  status == "paused"
170
174
  end
171
175
 
176
+ # The Play Store page where the customer manages this subscription (update the payment
177
+ # method, cancel, resubscribe). See Tillbell::GooglePlay.manage_url.
178
+ def manage_url
179
+ Tillbell::GooglePlay.manage_url(processor_plan)
180
+ end
181
+
182
+ # Price of one renewal in the units of its currency (a +BigDecimal+: 12.99 euros, 1200
183
+ # yen), as Google bills it in the customer's country, or nil when Google's answer has
184
+ # none. Meant for analytics and conversion events; show prices to customers from Google
185
+ # Play (the paywall does), which knows taxes and promotions. It is the price of the
186
+ # entitlement in force, so a subscription with add-ons reports the base item only.
187
+ def price_amount
188
+ price = recurring_price
189
+ return unless price
190
+
191
+ BigDecimal(price[:units].to_i) + (BigDecimal(price[:nanos].to_i) / 1_000_000_000)
192
+ end
193
+
194
+ # ISO 4217 code of the currency of +price_amount+ ("EUR"), or nil.
195
+ def price_currency
196
+ recurring_price&.dig(:currency_code)
197
+ end
198
+
199
+ # The purchase token of the subscription this purchase replaces. Google sets it when a
200
+ # purchase replaces another: a plan change (upgrade, downgrade, other billing period), a
201
+ # customer who signs up again for a subscription that was canceled but had not lapsed,
202
+ # and a prepaid plan converted to or topped up as an auto-renewing one.
203
+ def linked_purchase_token
204
+ google_object[:linked_purchase_token].presence
205
+ end
206
+
207
+ # True when this purchase replaces an earlier one: see +linked_purchase_token+ for the
208
+ # cases. Compare +processor_plan+ with the replaced subscription's to tell a plan change
209
+ # from a customer who signed up again for the same product.
210
+ def replacement?
211
+ linked_purchase_token.present?
212
+ end
213
+
214
+ # True when a newer purchase replaced this subscription (see +replacement?+). Tillbell
215
+ # ends a replaced subscription on its own and emits +subscription_expired+: check this to
216
+ # tell it from a customer who left. Set when the replacement is recorded, so it is
217
+ # already true inside the +subscription_expired+ callback; subscriptions replaced before
218
+ # this was added are not flagged.
219
+ def replaced?
220
+ replaced_by.present?
221
+ end
222
+
223
+ # The product the customer will move to at the next renewal, when a plan change was
224
+ # asked with the +DEFERRED+ replacement mode (the usual choice for a downgrade), or
225
+ # nil. Google reports it on the NEW purchase, in the line item of the entitlement that
226
+ # is about to end; until the renewal +processor_plan+ stays the current product.
227
+ def deferred_product_id
228
+ google_object[:line_items].to_a.filter_map { |item| item.dig(:deferred_item_replacement, :product_id).presence }.first
229
+ end
230
+
231
+ # True while a plan change waits for the next renewal. See +deferred_product_id+.
232
+ def plan_change_pending?
233
+ deferred_product_id.present?
234
+ end
235
+
172
236
  # Stops the renewal with Google; access continues until the end of the period.
173
- # A subscription that already stopped renewing is only refreshed: Google has nothing
174
- # to cancel, and deleting an owner (Pay calls +cancel_now!+ on every subscription
175
- # still granting access) must not fail on it.
237
+ # A subscription that already stopped renewing (or ended) has nothing to stop: it is only
238
+ # refreshed, or left alone. Deleting an owner (Pay calls +cancel_now!+ on every
239
+ # subscription still granting access) must not fail on it.
240
+ #
241
+ # Google answers 410 for a purchase token that expired more than 60 days ago. A
242
+ # subscription still stored as active (a missed notification) then has nothing left to
243
+ # stop: it is recorded as ended, instead of failing the deletion of its owner. Any other
244
+ # error (a 404 or 400 from a wrong package name, a 403) is raised: the subscription may
245
+ # still be billing.
176
246
  def cancel(**)
177
- Tillbell::GooglePlay.cancel(processor_plan, processor_id) if auto_renewing
247
+ return self if ended?
248
+
249
+ Tillbell::GooglePlay.cancel(processor_plan, processor_id) unless renewal_stopped?
178
250
  sync!
251
+ rescue Google::Apis::ClientError => error
252
+ raise unless error.status_code == 410
253
+
254
+ update!(status: "canceled", ends_at: [ current_period_end, Time.current ].compact.min, auto_renewing: false)
255
+ Tillbell.instrument(:subscription_expired, self)
256
+ self
179
257
  end
180
258
 
181
259
  # Google can only end a subscription immediately by refunding it, which Tillbell
@@ -192,6 +270,29 @@ module Pay
192
270
  raise Tillbell::Error, "Google Play subscriptions cannot #{method_name} from the server; use the app or the Play Store"
193
271
  end
194
272
  end
273
+
274
+ private
275
+ # True when Google already reports the subscription as canceled or expired. The state,
276
+ # not +auto_renewing+, which comes from the entitlement about to end and is false while a
277
+ # deferred plan change waits for the renewal, though the subscription will renew.
278
+ def renewal_stopped?
279
+ ENDING_STATES.include?(subscription_state)
280
+ end
281
+
282
+ # Google's last answer with indifferent keys: symbols while the subscription is the
283
+ # one just synced, strings once it is read back from the database.
284
+ def google_object
285
+ ActiveSupport::HashWithIndifferentAccess.new(object || {})
286
+ end
287
+
288
+ # The +recurring_price+ of the line item with the latest known expiry, i.e. the
289
+ # entitlement in force (Google lists the one about to end and the one that follows during
290
+ # a deferred plan change; the latter has no expiry yet and sorts first), as a hash with
291
+ # +units+, +nanos+ and +currency_code+.
292
+ def recurring_price
293
+ item = google_object[:line_items].to_a.max_by { |line| line[:expiry_time].to_s }
294
+ item&.dig(:auto_renewing_plan, :recurring_price)
295
+ end
195
296
  end
196
297
  end
197
298
  end
@@ -1,5 +1,6 @@
1
1
  require "google/apis/androidpublisher_v3"
2
2
  require "googleauth"
3
+ require "erb"
3
4
 
4
5
  module Tillbell
5
6
  # Thin client over the Google Play Developer API (Android Publisher v3).
@@ -80,6 +81,17 @@ module Tillbell
80
81
  subscription_purchase.dig(:external_account_identifiers, :obfuscated_external_account_id)
81
82
  end
82
83
 
84
+ # The Play Store page where a customer manages (updates payment, cancels, resubscribes)
85
+ # a subscription: the page of +product_id+, or the list of all subscriptions without
86
+ # it. Same page as the +manage+ message of the native component, for web pages and
87
+ # emails.
88
+ def manage_url(product_id = nil)
89
+ page = "https://play.google.com/store/account/subscriptions"
90
+ return page if product_id.blank?
91
+
92
+ "#{page}?sku=#{ERB::Util.url_encode(product_id)}&package=#{ERB::Util.url_encode(package_name)}"
93
+ end
94
+
83
95
  def package_name
84
96
  Tillbell.google.package_name.presence || raise(Error, "Tillbell.google.package_name is not set")
85
97
  end
@@ -1,3 +1,3 @@
1
1
  module Tillbell
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/tillbell.rb CHANGED
@@ -47,7 +47,25 @@ module Tillbell
47
47
  # +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
48
48
  mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
49
49
 
50
+ # What Hotwire Native writes in the user agent for the bridge components the app
51
+ # registered, e.g. "bridge-components: [haptic tillbell]". +tillbell+ must be in that
52
+ # list for the paywall to work.
53
+ NATIVE_USER_AGENT = /bridge-components: \[(?:[^\]]*\s)?tillbell[\s\]]/
54
+
50
55
  class << self
56
+ # True when +user_agent+ comes from a native app that registered the +tillbell+ bridge
57
+ # component (Android today, iOS later), so a Google Play purchase can start from the
58
+ # page. Use it to choose between the paywall and another way to pay, e.g. a web checkout:
59
+ #
60
+ # Tillbell.native?(request.user_agent)
61
+ #
62
+ # An app version without the component, and every browser, answer false. The check
63
+ # only picks which page to show: purchases are always verified with Google, so a
64
+ # forged user agent gains nothing.
65
+ def native?(user_agent)
66
+ NATIVE_USER_AGENT.match?(user_agent.to_s)
67
+ end
68
+
51
69
  # Yields the Tillbell module so settings can be assigned in a block.
52
70
  def configure
53
71
  yield self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tillbell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pierre Filstroff