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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3a7d7ef87518e743a31df158672d329430f1880a426cc734727147934435dad0
4
+ data.tar.gz: eb4af5eb06a08afd2936cb12bc7b0d3a77dac981dd9f8c36f2ec82562ef05c1b
5
+ SHA512:
6
+ metadata.gz: 5c610708ea4584f97b65e0379b0e83b543d696dede9a2f43a8d0273e751043b5f58e7763b8a6468b430bf426776aeab78d8280f424322c362cfde441e06ffc57
7
+ data.tar.gz: 4920b5c667d27df3813716ebd3413d34e6a3ccbb3b68af34337ea77afbc8253cd7849965b8e73258aeb56ba2805d603350e0879aa9db6742f564ead1e466c73f
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Added
10
+
11
+ - `Pay::GooglePlay` processor: Google Play subscriptions stored as Pay customers and
12
+ subscriptions, next to Stripe or any other Pay processor.
13
+ - Server-side purchase verification (`POST /tillbell/purchases`) with an ownership check
14
+ and server-side acknowledgment.
15
+ - Real-time developer notifications endpoint (`POST /tillbell/notifications/google_play`)
16
+ authenticated with the Pub/Sub OIDC token and processed through Pay's webhook pipeline.
17
+ - Events: `subscription_created`, `subscription_updated`, `subscription_canceled`,
18
+ `subscription_expired`, `subscription_revoked` and `purchase_rejected`.
19
+ - `tillbell_paywall` view helper, the `tillbell` Stimulus controller and the bridge
20
+ contract (JSON Schemas) implemented by the native libraries.
21
+ - `rails generate tillbell:install`.
22
+ - `google_play?` on Pay customers and subscriptions, and a `google_play` scope on both.
23
+
24
+ ### Fixed
25
+
26
+ - Paywall texts fall back to English in a locale that has no translation, instead of
27
+ rendering a "translation missing" marker.
28
+ - Deleting an owner no longer fails on a subscription that already stopped renewing.
29
+ - A refunded subscription stays `canceled` when a later read reports it as active.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Pierre Filstroff
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,341 @@
1
+ # Tillbell
2
+
3
+ [![CI](https://github.com/pmakerhq/tillbell-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/pmakerhq/tillbell-rails/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](MIT-LICENSE)
5
+
6
+ **Self-hosted in-app subscriptions for Rails + Hotwire Native apps, built on [Pay](https://github.com/pay-rails/pay).**
7
+
8
+ Your Android app sells subscriptions through Google Play. Your Rails app verifies them, acknowledges them and keeps them in sync. No third-party service sits between the store and your server, and no extra table is added: Google Play subscriptions are regular `Pay::Subscription` records, right next to your Stripe ones.
9
+
10
+ ```ruby
11
+ user.pay_subscriptions.active # Stripe and Google Play together
12
+ Pay::GooglePlay::Customer.for(user) # the Google Play customer, next to Stripe
13
+ subscription.active? # same rules as for Stripe
14
+ ```
15
+
16
+ > [!NOTE]
17
+ > **Status: v0.1, not released on RubyGems yet.** Google Play only for now. The App Store (StoreKit 2) is planned.
18
+
19
+ ## Table of contents
20
+
21
+ - [Why Tillbell](#why-tillbell)
22
+ - [Quick start](#quick-start)
23
+ - [How it works](#how-it-works)
24
+ - [Requirements](#requirements)
25
+ - [Installation](#installation)
26
+ - [Configuration](#configuration)
27
+ - [The paywall](#the-paywall)
28
+ - [Subscriptions and statuses](#subscriptions-and-statuses)
29
+ - [Events](#events)
30
+ - [Google Play setup](#google-play-setup)
31
+ - [Security](#security)
32
+ - [Troubleshooting](#troubleshooting)
33
+ - [The Tillbell family](#the-tillbell-family)
34
+ - [Getting help](#getting-help)
35
+ - [Contributing](#contributing)
36
+ - [Acknowledgements](#acknowledgements)
37
+ - [License](#license)
38
+
39
+ ## Why Tillbell
40
+
41
+ - **Your server is the source of truth.** Every purchase is read back from Google before it grants anything.
42
+ - **One subscription model.** Web (Stripe, Paddle, ...) and Android subscriptions share Pay's tables, scopes and helpers.
43
+ - **Access is immediate.** The paywall posts the purchase as soon as Google Play confirms it, without waiting for a notification.
44
+ - **No vendor in the middle.** Google's real-time notifications go straight to your app. You pay no revenue share and keep your data.
45
+ - **Small and replaceable.** A Rails engine, one Stimulus controller and a [documented bridge contract](docs/bridge-contract.md).
46
+
47
+ ## Quick start
48
+
49
+ Assuming a Rails 8 app with a `User` model and Pay installed:
50
+
51
+ 1. Add the gems and run the installer:
52
+
53
+ ```sh
54
+ bundle add pay
55
+ bundle add tillbell --github pmakerhq/tillbell-rails # until the first RubyGems release
56
+ bin/rails generate tillbell:install
57
+ ```
58
+
59
+ 2. Make the buyer a Pay customer:
60
+
61
+ ```ruby
62
+ class User < ApplicationRecord
63
+ pay_customer
64
+ end
65
+ ```
66
+
67
+ 3. Fill in `config/initializers/tillbell.rb`:
68
+
69
+ ```ruby
70
+ Tillbell.configure do |config|
71
+ config.google.package_name = "com.example.app"
72
+ config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
73
+ config.google.pubsub_service_account_email = "pubsub-push@my-project.iam.gserviceaccount.com"
74
+ end
75
+ ```
76
+
77
+ 4. Render a paywall:
78
+
79
+ ```erb
80
+ <%= tillbell_paywall success_url: dashboard_path do |paywall| %>
81
+ <%= paywall.plan_option product_id: "premium", base_plan_id: "monthly", checked: true do %>
82
+ Premium, <%= paywall.price %> per month
83
+ <% end %>
84
+ <%= paywall.submit %>
85
+ <%= paywall.fallback %>
86
+ <% end %>
87
+ ```
88
+
89
+ 5. Gate features with Pay, whatever the store:
90
+
91
+ ```ruby
92
+ current_user.pay_subscriptions.active.any?
93
+ ```
94
+
95
+ Then add [tillbell-android](https://github.com/pmakerhq/tillbell-android) to your Hotwire Native app and follow the [Google Play setup](#google-play-setup).
96
+
97
+ ## How it works
98
+
99
+ ```
100
+ tillbell_paywall (Rails view) ──► "products" ──► native app ──► localized prices from Google Play
101
+ │
102
+ ▼ tap "Subscribe"
103
+ Stimulus controller ──► "purchase" ──► native app ──► Google Play purchase sheet
104
+ │ (obfuscatedAccountId supplied by the server)
105
+ ▼
106
+ POST /tillbell/purchases (purchase token) ──► Rails reads the purchase from Google,
107
+ checks it belongs to the signed-in owner,
108
+ saves the Pay::Subscription, acknowledges it
109
+ Google Pub/Sub ──► POST /tillbell/notifications/google_play ──► Pay::Webhook ──► re-read from Google
110
+ ```
111
+
112
+ The page and the native app talk through a [Hotwire Native bridge component](https://native.hotwired.dev/overview/bridge-components) named `tillbell`. Its messages are specified in [docs/bridge-contract.md](docs/bridge-contract.md), with JSON Schemas in [contract/](contract).
113
+
114
+ - **The server never trusts the client.** A purchase made with another owner's account id is refused.
115
+ - **Notifications are triggers, not truth.** A notification only says which purchase changed. Tillbell reads the current state from Google, so duplicated or out-of-order notifications end in the same state.
116
+ - **Purchases are acknowledged on the server.** Google refunds subscriptions that are not acknowledged within three days. The native app never acknowledges.
117
+ - **Idempotent.** Syncing the same state twice changes nothing and emits no event.
118
+
119
+ ## Requirements
120
+
121
+ | | Version |
122
+ |---|---|
123
+ | Ruby | 3.3+ (tested on 3.3, 3.4 and 4.0) |
124
+ | Rails | 8.0+ (tested on 8.0 and 8.1) |
125
+ | Pay | 12+, installed and migrated |
126
+ | Active Job | A queue backend in production (Solid Queue, Sidekiq, ...) |
127
+ | Android app | Hotwire Native with [tillbell-android](https://github.com/pmakerhq/tillbell-android) |
128
+
129
+ ## Installation
130
+
131
+ ```ruby
132
+ # Gemfile
133
+ gem "pay"
134
+ gem "tillbell"
135
+ ```
136
+
137
+ ```sh
138
+ bundle install
139
+ bin/rails pay:install:migrations db:migrate # skip if you already use Pay
140
+ bin/rails generate tillbell:install
141
+ ```
142
+
143
+ The generator:
144
+
145
+ - creates `config/initializers/tillbell.rb`;
146
+ - mounts the engine with `mount Tillbell::Engine, at: "/tillbell"` (change the path if you like);
147
+ - copies the Stimulus controller to `app/javascript/controllers/tillbell_controller.js`;
148
+ - pins `@hotwired/hotwire-native-bridge` with importmap. With a JavaScript bundler, run `npm install @hotwired/hotwire-native-bridge` yourself.
149
+
150
+ The model that buys subscriptions must be a Pay customer:
151
+
152
+ ```ruby
153
+ class User < ApplicationRecord
154
+ pay_customer
155
+ end
156
+ ```
157
+
158
+ ## Configuration
159
+
160
+ ```ruby
161
+ # config/initializers/tillbell.rb
162
+ Tillbell.configure do |config|
163
+ config.google.package_name = "com.example.app"
164
+ config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
165
+ config.google.pubsub_service_account_email = "pubsub-push@my-project.iam.gserviceaccount.com"
166
+ end
167
+ ```
168
+
169
+ | Setting | Default | Description |
170
+ |---|---|---|
171
+ | `google.package_name` | (required) | Application id of the Android app. |
172
+ | `google.service_account_json` | (required) | JSON key (string or hash) of the service account that calls the Google Play Developer API. |
173
+ | `google.pubsub_service_account_email` | (required for notifications) | Service account the Pub/Sub push subscription signs its requests with. |
174
+ | `google.pubsub_audience` | endpoint URL | Audience of the Pub/Sub OIDC token. Set it when a proxy changes the URL Rails sees. |
175
+ | `current_owner` | `-> { current_user }` | Returns who is buying. Evaluated in the controller and in the view, so any helper available there works (`Current.user`, `current_account`, ...). |
176
+ | `parent_controller` | `"ApplicationController"` | Controller the purchase endpoint inherits from, to reuse your authentication, CSRF protection and error handling. |
177
+ | `subscription_name` | `->(product_id:, base_plan_id:) { Pay.default_product_name }` | Pay subscription `name` of a Google Play product, as used by `subscribed?(name:)`. |
178
+
179
+ ## The paywall
180
+
181
+ ```erb
182
+ <%= tillbell_paywall success_url: dashboard_path, class: "paywall" do |paywall| %>
183
+ <%= paywall.plan_option product_id: "premium", base_plan_id: "monthly", checked: true do %>
184
+ Monthly, <%= paywall.price %>
185
+ <% end %>
186
+ <%= paywall.plan_option product_id: "premium", base_plan_id: "yearly" do %>
187
+ Yearly, <%= paywall.price %>
188
+ <% end %>
189
+
190
+ <%= paywall.submit %>
191
+ <%= paywall.restore %>
192
+ <%= paywall.manage %>
193
+ <%= paywall.status %>
194
+ <%= paywall.fallback %>
195
+ <% end %>
196
+ ```
197
+
198
+ The markup is unstyled and every builder method accepts HTML attributes (`class:`, `data:`, ...). Texts default to the `tillbell.paywall` translations in [config/locales/en.yml](config/locales/en.yml). Override them in your locale files, or pass the text as the first argument (`paywall.submit "Go premium"`). A locale without translations falls back to English.
199
+
200
+ | Builder method | Renders |
201
+ |---|---|
202
+ | `plan_option(product_id:, base_plan_id: nil, checked: false, replacing: nil, replacement_mode: nil)` | A radio button wrapped in a label with the block's content. |
203
+ | `price(placeholder = "")` | Where the price localized by Google Play is written. Use it inside `plan_option`. |
204
+ | `submit(text)` | Starts the purchase of the checked plan. |
205
+ | `restore(text)` | Records again the purchases Google Play knows for the device, e.g. after a reinstall. |
206
+ | `manage(text)` | Opens the Play Store subscription page. |
207
+ | `status` | A polite live region for status messages ("We are checking your purchase", ...). |
208
+ | `fallback(text)` or `fallback { ... }` | Shown in a browser or in an app version without the native component. |
209
+
210
+ Prices always come from Google Play, never from your app.
211
+
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
+
214
+ **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
+
216
+ ```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 %>
221
+ <% end %>
222
+ ```
223
+
224
+ **JavaScript events.** The controller dispatches events on the paywall element:
225
+
226
+ - `tillbell:verified`: the purchase grants access. Call `event.preventDefault()` to stay on the page instead of visiting `success_url`.
227
+ - `tillbell:status`: a status message is shown (`event.detail` is `{ key, message }`).
228
+
229
+ The controller is copied into your app, so you can also change it.
230
+
231
+ ## Subscriptions and statuses
232
+
233
+ Google states are mapped to Pay statuses, so Pay helpers behave as they do for Stripe:
234
+
235
+ | Google `subscriptionState` | Pay `status` | `active?` | Notes |
236
+ |---|---|---|---|
237
+ | `PENDING` | `incomplete` | no | Payment not confirmed yet (e.g. cash). |
238
+ | `ACTIVE` | `active` | yes | |
239
+ | `IN_GRACE_PERIOD` | `active` | yes | Google requires access during the grace period. `in_grace_period?` is true. |
240
+ | `ON_HOLD` | `past_due` | no | Payment failed after the grace period. `on_hold?` is true. |
241
+ | `PAUSED` | `paused` | no | |
242
+ | `CANCELED` | `active` with `ends_at` | until `ends_at` | Will not renew. `canceled?` and `on_grace_period?` are true. |
243
+ | `EXPIRED` | `canceled` | no | |
244
+ | refunded (revoked) | `canceled`, `ends_at` = now | no | `revoked?` is true. |
245
+
246
+ Whether a status grants access is your decision. The recommended default is `subscription.active?`, which follows Google's rules above.
247
+
248
+ A `Pay::GooglePlay::Subscription` stores:
249
+
250
+ - `processor_id`: the purchase token;
251
+ - `processor_plan`: the Google product id;
252
+ - `data`: `base_plan_id`, `subscription_state`, `auto_renewing` and `revoked_at`;
253
+ - `object`: Google's last answer.
254
+
255
+ 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
+
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.
258
+
259
+ ## Events
260
+
261
+ ```ruby
262
+ Tillbell.configure do |config|
263
+ config.on(:subscription_created) { |subscription| WelcomeMailer.with(user: subscription.owner).premium.deliver_later }
264
+ config.on(:subscription_updated) { |subscription| } # renewal, grace period, hold, recovery, plan change
265
+ config.on(:subscription_canceled) { |subscription| } # will not renew; access until ends_at
266
+ config.on(:subscription_expired) { |subscription| }
267
+ config.on(:subscription_revoked) { |subscription| } # refunded or charged back
268
+ config.on(:purchase_rejected) { |details| } # { owner:, purchase_token: } posted by another account
269
+ end
270
+ ```
271
+
272
+ 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
+
274
+ ## Google Play setup
275
+
276
+ 1. **Service account.** In Google Cloud, create a service account and a JSON key, and enable the *Google Play Android Developer API*. In the Play Console, under *Users and permissions*, invite the service account with *View financial data* and *Manage orders and subscriptions*. Store the key in your credentials as `tillbell.google_service_account`.
277
+ 2. **Pub/Sub topic.** Create a topic, and grant `google-play-developer-notifications@system.gserviceaccount.com` the *Pub/Sub Publisher* role on it. In the Play Console, under *Monetization setup*, enter the topic name for real-time developer notifications.
278
+ 3. **Push subscription.** Create a push subscription on the topic:
279
+ - endpoint: `https://your-app.example.com/tillbell/notifications/google_play`;
280
+ - *Enable authentication* turned on, with a service account of your choice. Set its e-mail as `google.pubsub_service_account_email`;
281
+ - audience: leave it empty (it defaults to the endpoint URL), or set the same value as `google.pubsub_audience`.
282
+ 4. **Test.** Click *Send test notification* in the Play Console. The endpoint answers 200 and logs `[Tillbell] Google Play notification test_notification`.
283
+ 5. **License testers.** Add your testers in the Play Console, and install the app from a Play testing track to buy with test cards.
284
+
285
+ ## Security
286
+
287
+ - The purchase endpoint inherits from your `ApplicationController`, so your authentication and CSRF protection apply. It never trusts an owner or account id sent by the client.
288
+ - Every purchase is read back from Google, and the purchase's `obfuscatedAccountId` must match the signed-in owner's Google Play customer.
289
+ - Notifications must carry an OIDC token signed by Google for the configured service account. Anything else gets a 401.
290
+ - The account id sent to Google is a random UUID, never an e-mail or a database id.
291
+ - Purchase tokens are never logged in full.
292
+
293
+ Found a vulnerability? Please report it privately, as described in [SECURITY.md](SECURITY.md).
294
+
295
+ ## Troubleshooting
296
+
297
+ **The paywall buttons stay disabled.** The page is not running inside an app that registers the `tillbell` bridge component (a browser, or an app without [tillbell-android](https://github.com/pmakerhq/tillbell-android)). This is expected: the fallback is shown instead.
298
+
299
+ **`POST /tillbell/purchases` answers 401.** `current_owner` returned `nil`: the request is not signed in, or `current_owner` does not match your authentication.
300
+
301
+ **A purchase is `rejected`.** It was made with the Google Play account id of another owner, for example after switching users on the same device.
302
+
303
+ **A purchase stays in `verifying`.** Google could not be reached. The purchase is verified by `Tillbell::SyncJob`, so check that your Active Job backend is running.
304
+
305
+ **Notifications answer 401.** The Pub/Sub token does not match the configuration. Check `google.pubsub_service_account_email`, and set `google.pubsub_audience` when a proxy or load balancer changes the URL Rails sees.
306
+
307
+ ## The Tillbell family
308
+
309
+ | Repository | Purpose |
310
+ |---|---|
311
+ | **tillbell-rails** (this repo) | Rails engine: Pay processor, purchase verification, notifications, paywall helper |
312
+ | [tillbell-android](https://github.com/pmakerhq/tillbell-android) | Hotwire Native bridge component for Google Play Billing |
313
+ | [tillbell-ios](https://github.com/pmakerhq/tillbell-ios) | Hotwire Native bridge component for StoreKit 2 (planned) |
314
+
315
+ ## Getting help
316
+
317
+ - Questions and bug reports: [open an issue](https://github.com/pmakerhq/tillbell-rails/issues/new/choose).
318
+ - Security issues: see [SECURITY.md](SECURITY.md). Do not open a public issue.
319
+ - Changes between versions: [CHANGELOG.md](CHANGELOG.md).
320
+
321
+ ## Contributing
322
+
323
+ Bug reports, documentation fixes and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) to set up the project, run the tests and open a pull request. In short:
324
+
325
+ ```sh
326
+ git clone https://github.com/pmakerhq/tillbell-rails.git
327
+ cd tillbell-rails
328
+ bundle install
329
+ bin/rspec # test suite, with a dummy app in spec/dummy; Google is stubbed
330
+ bin/rubocop # rubocop-rails-omakase
331
+ ```
332
+
333
+ ## Acknowledgements
334
+
335
+ Tillbell is inspired by [PurchaseKit](https://github.com/PurchaseKit/purchasekit) by Joe Masilotti, which showed that a Rails gem plus small Hotwire Native bridge components is a good shape for in-app purchases, and which is MIT licensed. PurchaseKit routes store notifications through a hosted service. Tillbell is an independent project that does this part inside your own app. It is not affiliated with or endorsed by PurchaseKit. No file in this repository is derived from PurchaseKit's code.
336
+
337
+ Tillbell stands on [Pay](https://github.com/pay-rails/pay), by Chris Oliver and contributors.
338
+
339
+ ## License
340
+
341
+ Tillbell is open source under the [MIT License](MIT-LICENSE).
@@ -0,0 +1,71 @@
1
+ module Tillbell
2
+ # Receives Google Play real-time developer notifications from a Cloud Pub/Sub push
3
+ # subscription.
4
+ #
5
+ # The endpoint is public, so every request must carry an OIDC token signed by Google
6
+ # for the configured Pub/Sub service account; anything else gets a 401. Verified
7
+ # notifications are stored and processed by Pay (+Pay::Webhook+ and
8
+ # +Pay::Webhooks::ProcessJob+), exactly like Stripe webhooks, and answered with a 200
9
+ # straight away. A non-2xx answer would make Pub/Sub deliver the message again.
10
+ class NotificationsController < Pay::Webhooks::BaseController
11
+ # Notification kinds, as keys of the decoded Pub/Sub message.
12
+ # See https://developer.android.com/google/play/billing/rtdn-reference
13
+ KINDS = %w[subscriptionNotification voidedPurchaseNotification testNotification oneTimeProductNotification].freeze
14
+
15
+ # +productType+ of a voided purchase that is a subscription (2 is a one-time product).
16
+ VOIDED_SUBSCRIPTION = 1
17
+
18
+ private
19
+ # Verifies the Pub/Sub token and returns the decoded notification.
20
+ def verified_event
21
+ GooglePlay.verify_pubsub_token!(request.authorization.to_s.delete_prefix("Bearer "), audience: audience)
22
+
23
+ # Pub/Sub wraps the notification in a base64 encoded "data" field.
24
+ JSON.parse(params.dig(:message, :data).to_s.unpack1("m"))
25
+ rescue JSON::ParserError
26
+ raise Tillbell::Error, "Malformed Pub/Sub message"
27
+ end
28
+
29
+ def event_type(notification)
30
+ KINDS.find { |kind| notification.key?(kind) }&.underscore
31
+ end
32
+
33
+ # Only subscription changes of this app are processed; everything else is
34
+ # acknowledged so Pub/Sub stops sending it.
35
+ def queue?(notification)
36
+ kind = event_type(notification)
37
+ log(notification, kind)
38
+
39
+ return false unless notification["packageName"] == GooglePlay.package_name
40
+
41
+ case kind
42
+ when "subscription_notification" then true
43
+ when "voided_purchase_notification" then notification.dig("voidedPurchaseNotification", "productType") == VOIDED_SUBSCRIPTION
44
+ else false
45
+ end
46
+ end
47
+
48
+ # Pay derives the processor from the controller name; this one is not named after it.
49
+ def processor
50
+ "google_play"
51
+ end
52
+
53
+ # Pub/Sub signs its requests for the push endpoint URL unless configured otherwise.
54
+ def audience
55
+ Tillbell.google.pubsub_audience.presence || "#{request.base_url}#{request.path}"
56
+ end
57
+
58
+ def invalid_signature
59
+ head :unauthorized
60
+ end
61
+
62
+ # Logs the kind of notification and a shortened token, never the full token.
63
+ def log(notification, kind)
64
+ token = notification.dig("subscriptionNotification", "purchaseToken") ||
65
+ notification.dig("voidedPurchaseNotification", "purchaseToken")
66
+ type = notification.dig("subscriptionNotification", "notificationType")
67
+ Rails.logger.info "[Tillbell] Google Play notification #{kind || "unknown"}" \
68
+ "#{" type=#{type}" if type}#{" token=#{Tillbell.redact(token)}" if token}"
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,44 @@
1
+ module Tillbell
2
+ # Records a Google Play purchase made in the native app.
3
+ #
4
+ # The paywall posts the purchase token here as soon as the Play purchase sheet
5
+ # succeeds, so access is granted without waiting for a notification. The token is
6
+ # never trusted: Tillbell reads the purchase from Google and checks that it was made
7
+ # with the account id of the signed-in owner.
8
+ #
9
+ # Inherits from +Tillbell.parent_controller+ to run the host app's authentication and
10
+ # CSRF protection.
11
+ #
12
+ # Responds with JSON whose +status+ is one of:
13
+ #
14
+ # [verified] The purchase is recorded; +active+ tells whether it grants access yet
15
+ # (a pending purchase does not).
16
+ # [verifying] Google could not be reached; the purchase is verified in the background.
17
+ # [rejected] The purchase belongs to another account.
18
+ # [invalid] Google does not know this purchase token.
19
+ class PurchasesController < Tillbell.parent_controller.constantize
20
+ def create
21
+ owner = instance_exec(&Tillbell.current_owner)
22
+ return head :unauthorized if owner.nil?
23
+
24
+ purchase_token = params.expect(:purchase_token)
25
+ purchase = GooglePlay.subscription(purchase_token)
26
+
27
+ if GooglePlay.account_id(purchase) == Pay::GooglePlay::Customer.for(owner).processor_id
28
+ subscription = Pay::GooglePlay::Subscription.sync(purchase_token, object: purchase)
29
+ render json: { status: "verified", active: subscription.active?, subscription_status: subscription.status }
30
+ else
31
+ Tillbell.instrument(:purchase_rejected, owner: owner, purchase_token: Tillbell.redact(purchase_token))
32
+ render json: { status: "rejected" }, status: :forbidden
33
+ end
34
+ rescue *GooglePlay::TRANSIENT_ERRORS
35
+ # The job only trusts Google's answer, so it can safely finish without the owner check.
36
+ SyncJob.perform_later(purchase_token)
37
+ render json: { status: "verifying" }, status: :accepted
38
+ rescue Google::Apis::ClientError => error
39
+ raise unless GooglePlay.invalid_token?(error)
40
+
41
+ render json: { status: "invalid" }, status: :unprocessable_content
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,81 @@
1
+ module Tillbell
2
+ # Builds the parts of a paywall; yielded by +tillbell_paywall+.
3
+ #
4
+ # Every method takes HTML attributes, so the host app owns the markup and the styles.
5
+ # Texts default to the +tillbell.paywall+ translations.
6
+ class PaywallBuilder
7
+ def initialize(template)
8
+ @template = template
9
+ end
10
+
11
+ # A radio button for one Google Play base plan, wrapped in a label with the block's
12
+ # content. Pass +replacing+ (the current Pay::GooglePlay::Subscription) to make it a
13
+ # plan change; +replacement_mode+ is Google's proration mode, e.g.
14
+ # "CHARGE_PRORATED_PRICE", and is passed through untouched.
15
+ def plan_option(product_id:, base_plan_id: nil, checked: false, replacing: nil, replacement_mode: nil, **html_options, &block)
16
+ raise ArgumentError, "a plan change needs a replacement_mode" if replacing && replacement_mode.blank?
17
+
18
+ radio = tag.input(
19
+ type: "radio", name: "tillbell_plan", value: [ product_id, base_plan_id ].compact.join(":"),
20
+ checked: checked, disabled: true,
21
+ data: {
22
+ tillbell_target: "plan", product_id: product_id, base_plan_id: base_plan_id,
23
+ old_purchase_token: replacing&.processor_id, replacement_mode: replacement_mode
24
+ }.compact
25
+ )
26
+ tag.label(**html_options) { radio + @template.capture(&block) }
27
+ end
28
+
29
+ # Placeholder for the localized price, filled in from Google Play. Use it inside a
30
+ # +plan_option+ block.
31
+ def price(placeholder = "", **html_options)
32
+ tag.span(placeholder, **with_data(html_options, tillbell_target: "price"))
33
+ end
34
+
35
+ # Starts the purchase of the checked plan.
36
+ def submit(text = t("submit"), **html_options)
37
+ button(text, "purchase", **html_options)
38
+ end
39
+
40
+ # Asks Google Play for existing purchases and records them again, e.g. after a reinstall.
41
+ def restore(text = t("restore"), **html_options)
42
+ button(text, "restorePurchases", **html_options)
43
+ end
44
+
45
+ # Opens the Play Store subscription page.
46
+ def manage(text = t("manage"), **html_options)
47
+ button(text, "manage", **html_options)
48
+ end
49
+
50
+ # Live region where the controller writes status messages.
51
+ def status(**html_options)
52
+ tag.p(role: "status", aria: { live: "polite" }, **with_data(html_options, tillbell_target: "status"))
53
+ end
54
+
55
+ # Shown until the native component connects, i.e. in a browser or an outdated app.
56
+ def fallback(text = t("fallback"), **html_options, &block)
57
+ content = block ? @template.capture(&block) : text
58
+ tag.div(content, **with_data(html_options, tillbell_target: "fallback"))
59
+ end
60
+
61
+ private
62
+ def button(text, action, **html_options)
63
+ tag.button(text, type: "button", disabled: true,
64
+ **with_data(html_options, tillbell_target: "action", action: "tillbell##{action}"))
65
+ end
66
+
67
+ # Adds the data attributes the controller relies on to the caller's attributes,
68
+ # keeping any data attribute the caller passed.
69
+ def with_data(html_options, data)
70
+ html_options.merge(data: data.merge(html_options.fetch(:data, {})))
71
+ end
72
+
73
+ def tag
74
+ @template.tag
75
+ end
76
+
77
+ def t(key)
78
+ Tillbell.translate(key)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,40 @@
1
+ module Tillbell
2
+ # View helper rendering the Google Play paywall, available in every view of the host app.
3
+ module PaywallHelper
4
+ # Renders an unstyled paywall driven by the +tillbell+ Stimulus controller, which
5
+ # talks to the +tillbell+ Hotwire Native bridge component.
6
+ #
7
+ # <%= tillbell_paywall success_url: dashboard_path, class: "paywall" do |paywall| %>
8
+ # <%= paywall.plan_option product_id: "premium", base_plan_id: "monthly", checked: true do %>
9
+ # Monthly, <%= paywall.price %>
10
+ # <% end %>
11
+ # <%= paywall.submit %>
12
+ # <%= paywall.restore %>
13
+ # <%= paywall.manage %>
14
+ # <%= paywall.status %>
15
+ # <%= paywall.fallback %>
16
+ # <% end %>
17
+ #
18
+ # Outside the native app (or in an app version without the component) the controller
19
+ # never connects: the buttons stay disabled and only the fallback is shown.
20
+ #
21
+ # [success_url] Where to go once a purchase grants access.
22
+ # [owner] The Pay owner buying; defaults to +Tillbell.current_owner+.
23
+ # [html_options] Attributes of the wrapping +div+.
24
+ def tillbell_paywall(success_url:, owner: instance_exec(&Tillbell.current_owner), **html_options, &block)
25
+ raise ArgumentError, "tillbell_paywall needs a signed-in owner" if owner.nil?
26
+
27
+ data = {
28
+ controller: "tillbell",
29
+ tillbell_account_id_value: Pay::GooglePlay::Customer.for(owner).processor_id,
30
+ tillbell_purchase_url_value: tillbell.purchases_path,
31
+ tillbell_success_url_value: success_url,
32
+ tillbell_messages_value: Tillbell.translate("messages").to_json
33
+ }
34
+
35
+ tag.div(**html_options, data: data.merge(html_options.fetch(:data, {}))) do
36
+ capture(PaywallBuilder.new(self), &block)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,21 @@
1
+ module Tillbell
2
+ # Reads a Google Play purchase and updates its Pay subscription in the background.
3
+ #
4
+ # Enqueued for every real-time developer notification, and when Google could not be
5
+ # reached while recording a purchase. Google outages and rate limits are retried;
6
+ # a token Google does not know is dropped.
7
+ class SyncJob < ActiveJob::Base
8
+ queue_as :default
9
+
10
+ retry_on(*GooglePlay::TRANSIENT_ERRORS, wait: :polynomially_longer, attempts: 10)
11
+ discard_on(Google::Apis::ClientError) do |_job, error|
12
+ # Anything but an invalid token is a configuration problem worth failing loudly for.
13
+ raise error unless GooglePlay.invalid_token?(error)
14
+ end
15
+
16
+ # +revoked+ is true when the notification reports a refund.
17
+ def perform(purchase_token, revoked: false)
18
+ Pay::GooglePlay::Subscription.sync(purchase_token, revoked: revoked)
19
+ end
20
+ end
21
+ end