tillbell 0.1.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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +341 -0
  5. data/app/controllers/tillbell/notifications_controller.rb +71 -0
  6. data/app/controllers/tillbell/purchases_controller.rb +44 -0
  7. data/app/helpers/tillbell/paywall_builder.rb +81 -0
  8. data/app/helpers/tillbell/paywall_helper.rb +40 -0
  9. data/app/jobs/tillbell/sync_job.rb +21 -0
  10. data/app/models/pay/google_play/customer.rb +52 -0
  11. data/app/models/pay/google_play/subscription.rb +197 -0
  12. data/config/locales/en.yml +16 -0
  13. data/config/routes.rb +7 -0
  14. data/contract/examples/manage.request.json +3 -0
  15. data/contract/examples/products.reply.ok.json +15 -0
  16. data/contract/examples/products.reply.unavailable.json +4 -0
  17. data/contract/examples/products.request.json +15 -0
  18. data/contract/examples/purchase.reply.already_owned.json +3 -0
  19. data/contract/examples/purchase.reply.canceled.json +3 -0
  20. data/contract/examples/purchase.reply.error.json +4 -0
  21. data/contract/examples/purchase.reply.pending.json +5 -0
  22. data/contract/examples/purchase.reply.purchased.json +5 -0
  23. data/contract/examples/purchase.reply.unavailable.json +3 -0
  24. data/contract/examples/purchase.request.json +8 -0
  25. data/contract/examples/purchase.request.plan_change.json +7 -0
  26. data/contract/examples/restore.reply.empty.json +4 -0
  27. data/contract/examples/restore.reply.ok.json +9 -0
  28. data/contract/examples/restore.request.json +5 -0
  29. data/contract/manage.request.json +28 -0
  30. data/contract/products.reply.json +68 -0
  31. data/contract/products.request.json +47 -0
  32. data/contract/purchase.reply.json +54 -0
  33. data/contract/purchase.request.json +65 -0
  34. data/contract/restore.reply.json +58 -0
  35. data/contract/restore.request.json +19 -0
  36. data/lib/generators/tillbell/install/install_generator.rb +34 -0
  37. data/lib/generators/tillbell/install/templates/initializer.rb +33 -0
  38. data/lib/generators/tillbell/install/templates/tillbell_controller.js +147 -0
  39. data/lib/tillbell/engine.rb +42 -0
  40. data/lib/tillbell/google_play.rb +110 -0
  41. data/lib/tillbell/version.rb +3 -0
  42. data/lib/tillbell.rb +91 -0
  43. metadata +142 -0
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/restore.reply.json",
4
+ "title": "restore (native to page)",
5
+ "description": "Subscription purchases of the device's Google account. The page posts each token to the server.",
6
+ "type": "object",
7
+ "required": [
8
+ "result"
9
+ ],
10
+ "additionalProperties": false,
11
+ "properties": {
12
+ "result": {
13
+ "enum": [
14
+ "ok",
15
+ "unavailable",
16
+ "error"
17
+ ]
18
+ },
19
+ "purchases": {
20
+ "type": "array",
21
+ "items": {
22
+ "type": "object",
23
+ "required": [
24
+ "productId",
25
+ "purchaseToken"
26
+ ],
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "productId": {
30
+ "type": "string",
31
+ "minLength": 1,
32
+ "description": "Google Play product id (subscription id)."
33
+ },
34
+ "purchaseToken": {
35
+ "type": "string",
36
+ "minLength": 1
37
+ }
38
+ }
39
+ }
40
+ },
41
+ "message": {
42
+ "type": "string",
43
+ "description": "Developer-facing detail, never shown to the customer."
44
+ }
45
+ },
46
+ "if": {
47
+ "properties": {
48
+ "result": {
49
+ "const": "ok"
50
+ }
51
+ }
52
+ },
53
+ "then": {
54
+ "required": [
55
+ "purchases"
56
+ ]
57
+ }
58
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/restore.request.json",
4
+ "title": "restore (page to native)",
5
+ "description": "Asks the native app for the subscriptions Google Play reports for the device's Google account (queryPurchasesAsync).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "metadata": {
10
+ "type": "object",
11
+ "description": "Added by @hotwired/hotwire-native-bridge to every message sent by the page.",
12
+ "properties": {
13
+ "url": {
14
+ "type": "string"
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,34 @@
1
+ module Tillbell
2
+ module Generators
3
+ # Sets Tillbell up in a Rails app: +rails generate tillbell:install+.
4
+ #
5
+ # Tillbell stores everything in Pay's tables, so there is no migration to copy.
6
+ # Run Pay's own installation first if the app does not use Pay yet.
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates the Tillbell initializer, mounts the engine and copies the paywall Stimulus controller."
11
+
12
+ def create_initializer
13
+ copy_file "initializer.rb", "config/initializers/tillbell.rb"
14
+ end
15
+
16
+ def mount_engine
17
+ route 'mount Tillbell::Engine, at: "/tillbell"'
18
+ end
19
+
20
+ def copy_stimulus_controller
21
+ copy_file "tillbell_controller.js", "app/javascript/controllers/tillbell_controller.js"
22
+ end
23
+
24
+ def install_bridge_package
25
+ if File.exist?(File.join(destination_root, "config/importmap.rb"))
26
+ run "bin/importmap pin @hotwired/hotwire-native-bridge"
27
+ else
28
+ say "Add the Hotwire Native bridge to your JavaScript dependencies: " \
29
+ "npm install @hotwired/hotwire-native-bridge", :yellow
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # Tillbell: Google Play subscriptions stored as Pay subscriptions.
2
+ # See https://github.com/pmakerhq/tillbell-rails#configuration
3
+ Tillbell.configure do |config|
4
+ # Application id of the Android app.
5
+ config.google.package_name = "com.example.app"
6
+
7
+ # JSON key of a Google Cloud service account invited in the Play Console with the
8
+ # "View financial data" and "Manage orders and subscriptions" permissions.
9
+ config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
10
+
11
+ # Service account the Pub/Sub push subscription authenticates with.
12
+ config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
13
+
14
+ # Audience of the Pub/Sub OIDC token, if it is not the notification endpoint URL.
15
+ # config.google.pubsub_audience = "https://example.com/tillbell/notifications/google_play"
16
+
17
+ # Who is buying. Evaluated in the controller and the view of the paywall.
18
+ # config.current_owner = -> { current_user }
19
+
20
+ # Controller the purchase endpoint inherits from (authentication, CSRF, ...).
21
+ # config.parent_controller = "ApplicationController"
22
+
23
+ # Pay subscription name of a Google Play product, as used by `subscribed?(name:)`.
24
+ # config.subscription_name = ->(product_id:, base_plan_id:) { Pay.default_product_name }
25
+
26
+ # React to subscription changes. Each block receives the Pay::GooglePlay::Subscription.
27
+ # config.on(:subscription_created) { |subscription| }
28
+ # config.on(:subscription_updated) { |subscription| }
29
+ # config.on(:subscription_canceled) { |subscription| }
30
+ # config.on(:subscription_expired) { |subscription| }
31
+ # config.on(:subscription_revoked) { |subscription| }
32
+ # config.on(:purchase_rejected) { |details| } # a token posted by another account
33
+ end
@@ -0,0 +1,147 @@
1
+ // Tillbell paywall controller, copied into your app by `rails generate tillbell:install`.
2
+ // It is yours now: change it as you like. The messages it exchanges with the native app
3
+ // are documented in docs/bridge-contract.md of the tillbell gem.
4
+ //
5
+ // The controller only connects inside a Hotwire Native app that registers the
6
+ // "tillbell" bridge component. Everywhere else the paywall keeps its buttons disabled
7
+ // and shows its fallback, so a purchase can never start without the native app.
8
+ //
9
+ // Events dispatched on the paywall element, for your own code to listen to:
10
+ // tillbell:verified the purchase grants access. Cancel the event to stay on the page
11
+ // instead of going to the success URL.
12
+ // tillbell:status a status message is shown (detail: { key, message }).
13
+ import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
14
+
15
+ export default class extends BridgeComponent {
16
+ static component = "tillbell"
17
+ static targets = ["plan", "price", "action", "status", "fallback"]
18
+ static values = { accountId: String, purchaseUrl: String, successUrl: String, messages: Object }
19
+
20
+ connect() {
21
+ super.connect()
22
+ this.fallbackTargets.forEach((element) => element.hidden = true)
23
+ this.loadPrices()
24
+ }
25
+
26
+ // Asks Google Play for the localized price of every plan, then enables the paywall.
27
+ loadPrices() {
28
+ const products = this.planTargets.map((plan) => this.#productOf(plan))
29
+
30
+ this.send("products", { products }, ({ data }) => {
31
+ if (data.result !== "ok") return this.#showStatus(data.result === "unavailable" ? "unavailable" : "error")
32
+
33
+ this.planTargets.forEach((plan) => {
34
+ const { productId, basePlanId } = this.#productOf(plan)
35
+ const product = data.products.find((p) => p.productId === productId && (p.basePlanId ?? null) === basePlanId)
36
+ const price = plan.closest("label")?.querySelector("[data-tillbell-target~='price']")
37
+
38
+ if (price && product) price.textContent = product.formattedPrice
39
+ plan.disabled = !product
40
+ })
41
+ this.#setBusy(false)
42
+ })
43
+ }
44
+
45
+ // Opens the Google Play purchase sheet for the checked plan.
46
+ purchase() {
47
+ const plan = this.planTargets.find((plan) => plan.checked && !plan.disabled)
48
+ if (!plan) return
49
+
50
+ const { oldPurchaseToken, replacementMode } = plan.dataset
51
+ const message = { ...this.#productOf(plan), obfuscatedAccountId: this.accountIdValue }
52
+ if (oldPurchaseToken) Object.assign(message, { oldPurchaseToken, replacementMode })
53
+
54
+ this.#setBusy(true)
55
+ this.send("purchase", message, ({ data }) => this.#handlePurchase(data))
56
+ }
57
+
58
+ // Records again the purchases Google Play knows for this device's account.
59
+ restorePurchases() {
60
+ this.#setBusy(true)
61
+ this.send("restore", {}, async ({ data }) => {
62
+ if (data.result !== "ok") return this.#fail(data.result === "unavailable" ? "unavailable" : "error")
63
+ if (data.purchases.length === 0) return this.#fail("nothing_to_restore")
64
+
65
+ const results = []
66
+ for (const { purchaseToken } of data.purchases) results.push(await this.#record(purchaseToken))
67
+ this.#finish(results.find((result) => result.active) ?? results[0])
68
+ })
69
+ }
70
+
71
+ // Opens the Play Store page where the customer manages the subscription.
72
+ manage() {
73
+ const plan = this.planTargets.find((plan) => plan.checked) ?? this.planTargets[0]
74
+ this.send("manage", { productId: plan ? plan.dataset.productId : null })
75
+ }
76
+
77
+ async #handlePurchase({ result, purchaseToken }) {
78
+ switch (result) {
79
+ case "purchased":
80
+ case "pending":
81
+ return this.#finish(await this.#record(purchaseToken))
82
+ case "already_owned":
83
+ return this.restorePurchases()
84
+ case "canceled":
85
+ return this.#setBusy(false)
86
+ case "unavailable":
87
+ return this.#fail("unavailable")
88
+ default:
89
+ return this.#fail("error")
90
+ }
91
+ }
92
+
93
+ // Sends the purchase token to Rails, which verifies it with Google.
94
+ async #record(purchaseToken) {
95
+ this.#showStatus("verifying")
96
+ try {
97
+ const response = await fetch(this.purchaseUrlValue, {
98
+ method: "POST",
99
+ headers: {
100
+ "Content-Type": "application/json",
101
+ "Accept": "application/json",
102
+ "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
103
+ },
104
+ body: JSON.stringify({ purchase_token: purchaseToken })
105
+ })
106
+ return await response.json()
107
+ } catch {
108
+ return { status: "error" }
109
+ }
110
+ }
111
+
112
+ #finish(body) {
113
+ if (body.status === "verified" && body.active) {
114
+ const event = this.dispatch("verified", { detail: body, cancelable: true })
115
+ event.defaultPrevented ? this.#setBusy(false) : this.#visit(this.successUrlValue)
116
+ } else if (body.status === "verified") {
117
+ this.#fail("pending")
118
+ } else if (body.status === "verifying" || body.status === "rejected") {
119
+ this.#fail(body.status)
120
+ } else {
121
+ this.#fail("error")
122
+ }
123
+ }
124
+
125
+ #fail(key) {
126
+ this.#showStatus(key)
127
+ this.#setBusy(false)
128
+ }
129
+
130
+ #showStatus(key) {
131
+ const message = this.messagesValue[key] ?? key
132
+ this.statusTargets.forEach((element) => element.textContent = message)
133
+ this.dispatch("status", { detail: { key, message } })
134
+ }
135
+
136
+ #setBusy(busy) {
137
+ this.actionTargets.forEach((element) => element.disabled = busy)
138
+ }
139
+
140
+ #productOf(plan) {
141
+ return { productId: plan.dataset.productId, basePlanId: plan.dataset.basePlanId ?? null }
142
+ }
143
+
144
+ #visit(url) {
145
+ window.Turbo ? window.Turbo.visit(url) : window.location.assign(url)
146
+ }
147
+ }
@@ -0,0 +1,42 @@
1
+ module Tillbell
2
+ # Mountable engine providing the purchase and notification endpoints, the paywall
3
+ # helper and the Pay processor models.
4
+ #
5
+ # Mount it in the host app's routes:
6
+ #
7
+ # mount Tillbell::Engine, at: "/tillbell"
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace Tillbell
10
+
11
+ # Real-time developer notifications are verified, stored and queued by Pay (see
12
+ # Tillbell::NotificationsController). Once Pay's job processes one, Tillbell re-reads
13
+ # the subscription from Google.
14
+ initializer "tillbell.notifications" do
15
+ Pay::Webhooks.delegator.subscribe "google_play", ->(event) { Tillbell::GooglePlay.handle_notification(event) }
16
+ end
17
+
18
+ # Pay defines +stripe?+, +paddle_billing?+ and the matching scopes for its own
19
+ # processors only; this adds the same pair for Google Play.
20
+ initializer "tillbell.processor_helpers" do
21
+ ActiveSupport.on_load(:pay_customer) do
22
+ scope :google_play, -> { where(processor: "google_play") }
23
+
24
+ def google_play?
25
+ processor == "google_play"
26
+ end
27
+ end
28
+
29
+ ActiveSupport.on_load(:pay_subscription) do
30
+ scope :google_play, -> { joins(:customer).where(pay_customers: { processor: "google_play" }) }
31
+
32
+ def google_play?
33
+ customer.processor == "google_play"
34
+ end
35
+ end
36
+ end
37
+
38
+ initializer "tillbell.helpers" do
39
+ ActiveSupport.on_load(:action_view) { include Tillbell::PaywallHelper }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,110 @@
1
+ require "google/apis/androidpublisher_v3"
2
+ require "googleauth"
3
+
4
+ module Tillbell
5
+ # Thin client over the Google Play Developer API (Android Publisher v3).
6
+ #
7
+ # Every method returns plain hashes with snake_case symbol keys, so the rest of the
8
+ # gem (and its specs) never deal with the Google client classes.
9
+ module GooglePlay
10
+ SCOPE = "https://www.googleapis.com/auth/androidpublisher".freeze
11
+
12
+ # Errors worth retrying later: rate limiting, Google server errors and network failures.
13
+ TRANSIENT_ERRORS = [ Google::Apis::RateLimitError, Google::Apis::ServerError, Google::Apis::TransmissionError ].freeze
14
+
15
+ # HTTP statuses Google answers for a purchase token it does not know (invented,
16
+ # malformed, or expired for more than 60 days). Other client errors, such as a 403
17
+ # for a service account without access to the Play Console, are configuration
18
+ # problems and must not be mistaken for an invalid purchase.
19
+ INVALID_TOKEN_STATUSES = [ 400, 404, 410 ].freeze
20
+
21
+ # +notificationType+ of a subscription notification sent when a purchase is refunded.
22
+ # See https://developer.android.com/google/play/billing/rtdn-reference
23
+ SUBSCRIPTION_REVOKED = 12
24
+
25
+ class << self
26
+ # Handles a real-time developer notification (the decoded Pub/Sub message).
27
+ #
28
+ # A notification is a trigger, never a source of truth: it only says which purchase
29
+ # changed, and the job reads the actual state from Google. This keeps duplicated or
30
+ # out-of-order notifications harmless.
31
+ def handle_notification(notification)
32
+ if (subscription = notification["subscriptionNotification"])
33
+ revoked = subscription["notificationType"] == SUBSCRIPTION_REVOKED
34
+ Tillbell::SyncJob.perform_later(subscription["purchaseToken"], revoked: revoked)
35
+ elsif (voided = notification["voidedPurchaseNotification"])
36
+ Tillbell::SyncJob.perform_later(voided["purchaseToken"], revoked: true)
37
+ end
38
+ end
39
+
40
+ # Fetches the current state of a subscription purchase (+purchases.subscriptionsv2.get+).
41
+ def subscription(purchase_token)
42
+ service.get_purchase_subscriptionsv2(package_name, purchase_token).to_h
43
+ end
44
+
45
+ # Acknowledges a subscription purchase. Google refunds purchases that are not
46
+ # acknowledged within three days.
47
+ def acknowledge(product_id, purchase_token)
48
+ service.acknowledge_purchase_subscription(package_name, product_id, purchase_token)
49
+ end
50
+
51
+ # Stops the renewal of a subscription. The customer keeps access until the end of
52
+ # the current period.
53
+ def cancel(product_id, purchase_token)
54
+ service.cancel_purchase_subscription(package_name, product_id, purchase_token)
55
+ end
56
+
57
+ # Verifies the OIDC token Pub/Sub sends with a push request and returns its claims.
58
+ # Raises +Tillbell::Error+ unless Google signed it for +audience+ on behalf of the
59
+ # configured service account.
60
+ def verify_pubsub_token!(token, audience:)
61
+ claims = Google::Auth::IDTokens.verify_oidc(token.to_s, aud: audience)
62
+ expected = Tillbell.google.pubsub_service_account_email
63
+ unless expected.present? && claims["email"] == expected && claims["email_verified"]
64
+ raise Error, "Pub/Sub token was not issued for the configured service account"
65
+ end
66
+
67
+ claims
68
+ rescue Google::Auth::IDTokens::VerificationError => error
69
+ raise Error, error.message
70
+ end
71
+
72
+ # True when +error+ means that the purchase token itself is invalid.
73
+ def invalid_token?(error)
74
+ error.is_a?(Google::Apis::ClientError) && INVALID_TOKEN_STATUSES.include?(error.status_code)
75
+ end
76
+
77
+ # The +obfuscatedAccountId+ the purchase was made with, i.e. the +processor_id+ of
78
+ # the Pay::GooglePlay::Customer it belongs to.
79
+ def account_id(subscription_purchase)
80
+ subscription_purchase.dig(:external_account_identifiers, :obfuscated_external_account_id)
81
+ end
82
+
83
+ def package_name
84
+ Tillbell.google.package_name.presence || raise(Error, "Tillbell.google.package_name is not set")
85
+ end
86
+
87
+ # Memoized API client. Clear it with +reset!+ after changing credentials.
88
+ def service
89
+ @service ||= Google::Apis::AndroidpublisherV3::AndroidPublisherService.new.tap do |service|
90
+ service.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
91
+ json_key_io: StringIO.new(service_account_json), scope: SCOPE
92
+ )
93
+ end
94
+ end
95
+
96
+ def reset!
97
+ @service = nil
98
+ end
99
+
100
+ private
101
+ # Accepts the key as a JSON string or as a hash (e.g. from Rails credentials).
102
+ def service_account_json
103
+ json = Tillbell.google.service_account_json
104
+ raise Error, "Tillbell.google.service_account_json is not set" if json.blank?
105
+
106
+ json.is_a?(String) ? json : json.to_json
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module Tillbell
2
+ VERSION = "0.1.0"
3
+ end
data/lib/tillbell.rb ADDED
@@ -0,0 +1,91 @@
1
+ require "pay"
2
+ require "tillbell/version"
3
+ require "tillbell/engine"
4
+ require "tillbell/google_play"
5
+
6
+ # Tillbell adds Google Play subscriptions to Rails apps that use the Pay gem.
7
+ #
8
+ # Store subscriptions are regular Pay records (a +Pay::GooglePlay::Customer+ and its
9
+ # +Pay::GooglePlay::Subscription+ rows), so they live next to Stripe or any other Pay
10
+ # processor and every Pay helper (+pay_subscriptions.active+, +subscribed?+, ...) sees them.
11
+ #
12
+ # Configure it in an initializer:
13
+ #
14
+ # Tillbell.configure do |config|
15
+ # config.google.package_name = "com.example.app"
16
+ # config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google)
17
+ # config.google.pubsub_service_account_email = "pubsub-push@example.iam.gserviceaccount.com"
18
+ #
19
+ # config.on(:subscription_created) { |pay_subscription| ... }
20
+ # end
21
+ module Tillbell
22
+ # Base class for every error raised by Tillbell.
23
+ class Error < Pay::Error; end
24
+
25
+ # The events Tillbell emits, in the order a subscription usually goes through them.
26
+ EVENTS = %i[subscription_created subscription_updated subscription_canceled subscription_expired subscription_revoked
27
+ purchase_rejected].freeze
28
+
29
+ # Google Play settings, see the README for where each value comes from.
30
+ #
31
+ # [package_name] Application id of the Android app, e.g. "com.example.app".
32
+ # [service_account_json] JSON key of a service account with access to the Play Console.
33
+ # [pubsub_service_account_email] Service account Pub/Sub signs its push requests with.
34
+ # [pubsub_audience] Audience of those requests; defaults to the notification endpoint URL.
35
+ mattr_accessor :google, default: ActiveSupport::OrderedOptions.new
36
+
37
+ # Returns the Pay owner (usually a User) the current request acts for, or nil.
38
+ # Evaluated with +instance_exec+ in the controller and in the view, so any helper
39
+ # available there (+current_user+, +Current.user+, ...) can be used.
40
+ mattr_accessor :current_owner, default: -> { current_user }
41
+
42
+ # Controller the purchase endpoint inherits from, so it gets the host app's
43
+ # authentication, CSRF protection and error handling.
44
+ mattr_accessor :parent_controller, default: "ApplicationController"
45
+
46
+ # Returns the Pay subscription +name+ for a Google Play product. Pay helpers such as
47
+ # +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
48
+ mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
49
+
50
+ class << self
51
+ # Yields the Tillbell module so settings can be assigned in a block.
52
+ def configure
53
+ yield self
54
+ end
55
+
56
+ # Subscribes to a Tillbell event. The block receives the +Pay::GooglePlay::Subscription+
57
+ # (or, for +:purchase_rejected+, a hash describing the refused purchase).
58
+ #
59
+ # Events go through Pay's webhook delegator, so +Pay::Webhooks.delegator.subscribe
60
+ # "tillbell.subscription_created"+ works too.
61
+ def on(event, callable = nil, &block)
62
+ raise ArgumentError, "unknown Tillbell event #{event.inspect}" unless EVENTS.include?(event.to_sym)
63
+
64
+ Pay::Webhooks.delegator.subscribe("tillbell.#{event}", callable || block)
65
+ end
66
+
67
+ # Emits a Tillbell event. Errors raised by subscribers are reported to +Rails.error+
68
+ # instead of propagating: by the time an event fires, the subscription is already
69
+ # saved, and a failing callback must not turn a recorded purchase into an error.
70
+ def instrument(event, payload)
71
+ Pay::Webhooks.instrument(type: "tillbell.#{event}", event: payload)
72
+ rescue StandardError => error
73
+ Rails.error.report(error, handled: true, context: { tillbell_event: event })
74
+ end
75
+
76
+ # Paywall text +key+ (see config/locales/en.yml) in the current locale. A locale without
77
+ # the text, or with only part of the status messages, falls back to English instead of
78
+ # rendering a "translation missing" marker the Stimulus controller cannot parse.
79
+ def translate(key)
80
+ english = I18n.t("tillbell.paywall.#{key}", locale: :en)
81
+ localized = I18n.t("tillbell.paywall.#{key}", default: english)
82
+ english.is_a?(Hash) ? english.merge(localized) : localized
83
+ end
84
+
85
+ # Shortens a purchase token for logs: tokens grant access to a subscription and
86
+ # must never be written in full.
87
+ def redact(token)
88
+ token.to_s.first(8) + "..."
89
+ end
90
+ end
91
+ end