tillbell 0.1.1 → 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: 72c52a2e210cb60ee2fa3173935075173b3c90a1e30574c7d0b381210c8374f2
4
- data.tar.gz: d7d9d6f30c25c140ee124e8de0f567995a57b31a8a211ca76905859f6c78d875
3
+ metadata.gz: 12dfa4c6d7a3864417897be9a747fefbe25e4b1507665cea18427357bde1d6e6
4
+ data.tar.gz: 30ad262de35244e9262452acce2734ee92245274bd66f6f74205f798d5e01695
5
5
  SHA512:
6
- metadata.gz: 6b9e6e9f84d106ed21019f130acc49913a6dea88014d0c3eaa4b5bcb777d957eafe2173b5ea7892bba9f7eacfbe4421415eb5787c0b47eb696db0fcc2c0d0681
7
- data.tar.gz: 9f8314103d9234e46279cd3625fe2603c340e42889435d0adfbd6ef245eca82b7dd354cef6513a78aa3f3363dbf06586beeb9e1f28778a948a364c17450a8f26
6
+ metadata.gz: 5d140d39754930be99c4c93de83e51b2947e609e3bff3b0629bad2a95421e806c83e14ce08df900bb78049e869f23802e6d0692506c012feaa58d36649a56de2
7
+ data.tar.gz: e8118449e7d79da14d3c6b7fc40b0e23aaf20c342343e8f1e77928b15ad4d3ed2a4a6bd14b3915ebb5aa9af1616e5fdc38dcbb1f2fc70f2c423eb7d399599257
data/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ All notable changes to this project are documented in this file. The format foll
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - `bin/rails tillbell:check` (`Tillbell::Check`): checks the package name, the service account
14
+ key, that the engine is mounted and the notification sender, without printing a secret.
15
+ `ONLINE=1` also asks Google (the key, its rights, the subscriptions of the Play Console);
16
+ `PLANS=premium:monthly,premium:yearly` checks the plans the app sells exist and are active.
17
+ - The install generator prints the credentials template and the next steps.
18
+
19
+ ### Changed
20
+
21
+ - README, "Google Play setup": rewritten for someone who has never touched Google Play's
22
+ consoles. What Google needs and why, a credentials template to fill in, a checklist, every
23
+ step in the Play Console and the Cloud Console, the check, a test purchase and a
24
+ troubleshooting table.
25
+
26
+ ## [0.2.0] - 2026-10-30
27
+
28
+ ### Added
29
+
30
+ - `Tillbell.native?(user_agent)` and the `tillbell_native?` view helper: tell whether the
31
+ app showing the page registered the `tillbell` component, to choose between the paywall
32
+ and another way to pay.
33
+ - Details of a `Pay::GooglePlay::Subscription` read from Google's last answer:
34
+ `price_amount` and `price_currency`, `replacement?`, `linked_purchase_token`, `replaced?`,
35
+ `plan_change_pending?`, `deferred_product_id` and `manage_url`.
36
+ - `Tillbell::GooglePlay.manage_url(product_id = nil)`: the Play Store page of a
37
+ subscription, for web pages and emails.
38
+ - README: detecting the native app, plan changes (replacement modes, `DEFERRED`
39
+ downgrades), subscription details, several processors per customer, closing an account,
40
+ and events during a plan change.
41
+
42
+ ### Changed
43
+
44
+ - A replaced subscription remembers the purchase token that replaced it (`data["replaced_by"]`)
45
+ and keeps its ended state: a later read of the old token no longer brings it back.
46
+ - `cancel` decides whether to ask Google from its subscription state (canceled or expired)
47
+ instead of `auto_renewing`, which is false while a deferred plan change waits for the renewal
48
+ though the subscription will renew.
49
+
50
+ ### Fixed
51
+
52
+ - `cancel` (and `cancel_now!`) on a subscription Google answers 410 for (a purchase token
53
+ expired for more than 60 days) no longer raises: it made closing an account or deleting an
54
+ owner fail for a customer who left long ago, including for a row still stored as active
55
+ because a notification was missed. The subscription is recorded as ended and
56
+ `subscription_expired` is emitted once. Other errors still raise.
57
+
9
58
  ## [0.1.1] - 2026-10-30
10
59
 
11
60
  ### Changed
data/README.md CHANGED
@@ -64,16 +64,18 @@ Assuming a Rails 8 app with a `User` model and Pay installed:
64
64
  end
65
65
  ```
66
66
 
67
- 3. Fill in `config/initializers/tillbell.rb`:
67
+ 3. Fill in `config/initializers/tillbell.rb`, and put the two values Google gives you in your credentials (the [Google Play setup](#google-play-setup) below has the template and every click):
68
68
 
69
69
  ```ruby
70
70
  Tillbell.configure do |config|
71
71
  config.google.package_name = "com.example.app"
72
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"
73
+ config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
74
74
  end
75
75
  ```
76
76
 
77
+ Then `bin/rails tillbell:check` tells you what is still missing.
78
+
77
79
  4. Render a paywall:
78
80
 
79
81
  ```erb
@@ -92,7 +94,7 @@ Assuming a Rails 8 app with a `User` model and Pay installed:
92
94
  current_user.pay_subscriptions.active.any?
93
95
  ```
94
96
 
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).
97
+ Then add [tillbell-android](https://github.com/pmakerhq/tillbell-android) to your Hotwire Native app, and do the [Google Play setup](#google-play-setup) if you have not yet: it is the part that happens on Google's side.
96
98
 
97
99
  ## How it works
98
100
 
@@ -162,10 +164,12 @@ end
162
164
  Tillbell.configure do |config|
163
165
  config.google.package_name = "com.example.app"
164
166
  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"
167
+ config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
166
168
  end
167
169
  ```
168
170
 
171
+ The values marked "credentials" below come from Google: the [Google Play setup](#google-play-setup) says where to find each one, and gives the template to fill in. `bin/rails tillbell:check` verifies them.
172
+
169
173
  | Setting | Default | Description |
170
174
  |---|---|---|
171
175
  | `google.package_name` | (required) | Application id of the Android app. |
@@ -211,16 +215,46 @@ Prices always come from Google Play, never from your app.
211
215
 
212
216
  **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
217
 
218
+ **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:
219
+
220
+ ```erb
221
+ <% if tillbell_native? %>
222
+ <%= tillbell_paywall success_url: dashboard_path do |paywall| %> ... <% end %>
223
+ <% else %>
224
+ <%= link_to "Subscribe on the web", checkout_path %>
225
+ <% end %>
226
+ ```
227
+
228
+ 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.
229
+
214
230
  **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
231
 
216
232
  ```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 %>
233
+ <%= tillbell_paywall success_url: settings_path do |paywall| %>
234
+ <%= paywall.plan_option product_id: "family", base_plan_id: "monthly", checked: true,
235
+ replacing: current_user.pay_subscriptions.google_play.active.first, # the current Pay::GooglePlay::Subscription
236
+ replacement_mode: "CHARGE_PRORATED_PRICE" do %>
237
+ Switch to Family, <%= paywall.price %>
238
+ <% end %>
239
+ <%= paywall.submit "Switch to Family" %>
240
+ <%= paywall.status %>
221
241
  <% end %>
222
242
  ```
223
243
 
244
+ | Replacement mode | Use it for | Effect |
245
+ |---|---|---|
246
+ | `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. |
247
+ | `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. |
248
+ | `WITHOUT_PRORATION` | An upgrade that keeps a free period | Takes effect now. The new price applies from the next renewal. |
249
+ | `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. |
250
+ | `DEFERRED` | A downgrade to a cheaper plan | Takes effect at the next renewal: the customer keeps what they paid for until then. |
251
+
252
+ 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.
253
+
254
+ 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)).
255
+
256
+ 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.
257
+
224
258
  **JavaScript events.** The controller dispatches events on the paywall element:
225
259
 
226
260
  - `tillbell:verified`: the purchase grants access. Call `event.preventDefault()` to stay on the page instead of visiting `success_url`.
@@ -252,9 +286,30 @@ A `Pay::GooglePlay::Subscription` stores:
252
286
  - `data`: `base_plan_id`, `subscription_state`, `auto_renewing` and `revoked_at`;
253
287
  - `object`: Google's last answer.
254
288
 
289
+ **Details from Google.** These read Google's last answer, so they cost no API call:
290
+
291
+ | Method | Returns |
292
+ |---|---|
293
+ | `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. |
294
+ | `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. |
295
+ | `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. |
296
+ | `plan_change_pending?`, `deferred_product_id` | A `DEFERRED` plan change waiting for the next renewal, and the product it goes to. |
297
+ | `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. |
298
+ | `in_grace_period?`, `on_hold?`, `revoked?` | The Google states that Pay statuses merge. |
299
+
300
+ 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.
301
+
255
302
  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
303
 
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.
304
+ `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.
305
+
306
+ **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:
307
+
308
+ ```ruby
309
+ user.pay_subscriptions.where(status: %w[active past_due paused]).reject(&:ended?).each(&:cancel_now!)
310
+ ```
311
+
312
+ 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
313
 
259
314
  ## Events
260
315
 
@@ -269,18 +324,200 @@ Tillbell.configure do |config|
269
324
  end
270
325
  ```
271
326
 
327
+ 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:
328
+
329
+ ```ruby
330
+ Tillbell.on(:subscription_updated) do |subscription|
331
+ changes = subscription.saved_changes.keys
332
+ notify("Payment on hold") if changes.include?("status") && subscription.on_hold?
333
+ end
334
+
335
+ Tillbell.on(:subscription_expired) do |subscription|
336
+ notify("Subscription ended") unless subscription.replaced? # not a plan change
337
+ end
338
+ ```
339
+
272
340
  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
341
 
274
342
  ## Google Play setup
275
343
 
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.
344
+ Tillbell needs two things from Google: **permission to read and acknowledge your customers' purchases**, and **a way to hear about renewals, cancellations and refunds**. Setting that up takes about 30 minutes of clicking in two Google websites:
345
+
346
+ | Website | What lives there | Called here |
347
+ |---|---|---|
348
+ | [Google Play Console](https://play.google.com/console) | Your app, your subscriptions, who may access them | **Play Console** |
349
+ | [Google Cloud Console](https://console.cloud.google.com) | Service accounts (the identities your server uses) and Pub/Sub (the mailbox Google drops notifications in) | **Cloud Console** |
350
+
351
+ You end up with **two values to give your app**: the JSON key of a service account, and the e-mail address of a second service account.
352
+
353
+ > [!NOTE]
354
+ > Google's menus move now and then. The names below are the ones to look for; if one has changed, search the console for it.
355
+
356
+ ### Before you start
357
+
358
+ - A Google Play developer account, with your app created in the Play Console.
359
+ - A payments profile (merchant account) linked to the developer account: Google asks for it when you create a paid product.
360
+ - **A build of your app that includes the Google Play Billing Library, published to a testing track** (the Internal testing track is enough). Google does not let you create subscriptions until it has seen one. [tillbell-android](https://github.com/pmakerhq/tillbell-android) brings the Billing Library, so upload a build of your Hotwire Native app that includes it.
361
+ - Your Rails app deployed on a public HTTPS address (Google must be able to call it).
362
+
363
+ ### The template to fill in
364
+
365
+ Put the two values in your Rails credentials (`bin/rails credentials:edit`, or `bin/rails credentials:edit --environment production`), under the `tillbell` key. The generated initializer reads them from there. Replace everything between `<` and `>`:
366
+
367
+ ```yaml
368
+ tillbell:
369
+ # Step 2: the whole JSON key file of the service account that calls Google.
370
+ # Keep the "|" and the two-space indentation, or the key is unreadable.
371
+ google_service_account: |
372
+ {
373
+ "type": "service_account",
374
+ "project_id": "<your-cloud-project>",
375
+ "private_key_id": "...",
376
+ "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
377
+ "client_email": "<tillbell-api>@<your-cloud-project>.iam.gserviceaccount.com",
378
+ "client_id": "...",
379
+ "token_uri": "https://oauth2.googleapis.com/token"
380
+ }
381
+ # Step 4: the e-mail address of the service account Pub/Sub signs its notifications with.
382
+ pubsub_service_account_email: <tillbell-push>@<your-cloud-project>.iam.gserviceaccount.com
383
+ ```
384
+
385
+ Two more settings live in the initializer, not in the credentials: `config.google.package_name` (the application id of your Android app, for example `com.example.app`) and, only if a proxy changes the address Rails sees, `config.google.pubsub_audience` (see [Configuration](#configuration)).
386
+
387
+ `rails generate tillbell:install` prints this template when it finishes.
388
+
389
+ ### The checklist
390
+
391
+ Copy it into your tracker and tick it as you go. Each step is explained below.
392
+
393
+ ```markdown
394
+ - [ ] 0. Developer account, app created, payments profile, a build with the Billing Library on a testing track
395
+ - [ ] 1. Subscriptions created in the Play Console, base plan active (note the product and base plan ids)
396
+ - [ ] 2. Service account "tillbell-api" created, API enabled, JSON key downloaded
397
+ - [ ] 3. That service account invited in the Play Console with the two permissions (then wait, up to a day)
398
+ - [ ] 4. Service account "tillbell-push" created (no role, no key), e-mail copied
399
+ - [ ] 5. Pub/Sub topic created, Google Play allowed to publish to it
400
+ - [ ] 6. Push subscription created: your URL, authentication on, "tillbell-push"
401
+ - [ ] 7. Credentials filled in and deployed, `bin/rails tillbell:check` passes
402
+ - [ ] 8. Topic connected in the Play Console, test message sent and received
403
+ - [ ] 9. License testers added, test purchase made
404
+ ```
405
+
406
+ ### 1. Create your subscriptions (Play Console)
407
+
408
+ *Monetize with Play* → *Products* → *Subscriptions* → *Create subscription*.
409
+
410
+ - The **product ID** names the subscription (`premium`). You pass it as `product_id:` to `plan_option`.
411
+ - Each subscription has one or more **base plans** (`monthly`, `yearly`), with their own ID, billing period and price. You pass it as `base_plan_id:`. A base plan must be **Active** to be sold.
412
+ - Neither ID can be changed later, so pick them carefully.
413
+
414
+ ### 2. Create the service account that calls Google (Cloud Console)
415
+
416
+ A *service account* is an identity for a program instead of a person. Your server uses this one to ask Google "what is the state of this purchase?" and to acknowledge it.
417
+
418
+ 1. Pick (or create) a Cloud project. Any project works: what ties the service account to your app is the invitation of step 3.
419
+ 2. *APIs & Services* → *Library* → search **Google Play Android Developer API** → *Enable*.
420
+ 3. *IAM & Admin* → *Service Accounts* → *Create service account*. Name it `tillbell-api`. Give it **no role** here.
421
+ 4. Open it → *Keys* → *Add key* → *Create new key* → **JSON**. A file downloads. **Google shows it only once**: that file is the `google_service_account` of the template. Treat it like a password, and never commit it.
422
+
423
+ Its e-mail address (`client_email` in the file) is needed in the next step. If *Create new key* is greyed out or refused, an organization policy blocks service account keys: see [If something is off](#if-something-is-off).
424
+
425
+ ### 3. Let that service account into the Play Console
426
+
427
+ The Cloud Console knows the account exists. The Play Console must also allow it.
428
+
429
+ *Users and permissions* → *Invite new users* → paste the service account's e-mail address, then open the **App permissions** tab → *Add app* → your app, and tick:
430
+
431
+ - **View financial data** (the account-level tab calls it *View financial data, orders and cancellation survey responses*);
432
+ - **Manage orders and subscriptions**.
433
+
434
+ Send the invitation. A service account does not need to accept it, but Google can take **up to a day** to apply the permissions, so a `401` or `403` in step 7 right after this step is normal.
435
+
436
+ ### 4. Create the account that signs notifications (Cloud Console)
437
+
438
+ When Google tells your server about a renewal, your server must know the call really comes from Google's Pub/Sub. Pub/Sub proves it by signing each call with a service account of your choice. You create that one now, and give your app its e-mail address so it can recognize the signature.
439
+
440
+ *IAM & Admin* → *Service Accounts* → *Create service account*, named `tillbell-push`. It needs **no role and no key**: it is only a name Pub/Sub signs with. Copy its e-mail address: that is `pubsub_service_account_email` of the template.
441
+
442
+ ### 5. Create the Pub/Sub topic (Cloud Console)
443
+
444
+ A *topic* is the mailbox Google Play drops notifications in.
445
+
446
+ 1. *Pub/Sub* → *Topics* → *Create topic* (enable the Pub/Sub API if asked). Name it `play-notifications`. Note its full name: `projects/<your-cloud-project>/topics/play-notifications`.
447
+ 2. Open the topic → *Permissions* → *Add principal*: `google-play-developer-notifications@system.gserviceaccount.com`, role **Pub/Sub Publisher**. That is Google Play itself, allowed to write in your mailbox.
448
+
449
+ ### 6. Make the topic call your server (Cloud Console)
450
+
451
+ A *push subscription* forwards each message to a URL.
452
+
453
+ Open the topic → *Create subscription*:
454
+
455
+ - **Delivery type**: *Push*.
456
+ - **Endpoint URL**: `https://<your-domain>/tillbell/notifications/google_play` (the path follows where you mounted the engine). It must be public HTTPS, with **no query string and no trailing slash**: the audience below is compared to it exactly. Behind a proxy that terminates TLS, Rails must know the request was HTTPS (`X-Forwarded-Proto`).
457
+ - **Enable authentication**: ticked, with the service account of step 4 (`tillbell-push`).
458
+ - **Audience**: leave empty. It then defaults to the endpoint URL, which is what Tillbell expects.
459
+
460
+ Two permissions can get in the way:
461
+
462
+ - If Google refuses to create the subscription with authentication, your own Google account needs the **Service Account User** role on `tillbell-push`.
463
+ - Pub/Sub's own service agent (`service-<project-number>@gcp-sa-pubsub.iam.gserviceaccount.com`) must be allowed to create tokens for `tillbell-push`: give it the **Service Account Token Creator** role on that account. Google's console steps say to check it, and projects that enabled Pub/Sub long ago need it explicitly. Without it, pushes fail silently.
464
+
465
+ ### 7. Fill in the credentials, deploy and check
466
+
467
+ Put the two values in your credentials (see the template), set `config.google.package_name`, and **deploy**: the next step needs your server to be able to accept Google's notifications. Then run the check where the credentials are available. The simplest is on the server; locally, `RAILS_ENV=production` boots your production environment, which may need its other environment variables (database, `SECRET_KEY_BASE`) and your production key.
468
+
469
+ ```sh
470
+ bin/rails tillbell:check # reads your configuration, asks nobody
471
+ ONLINE=1 PLANS=premium:monthly,premium:yearly bin/rails tillbell:check # also asks Google
472
+ ```
473
+
474
+ `PLANS` lists the `product_id:base_plan_id` pairs your app sells; each must exist in the Play Console with an active base plan. Without `PLANS`, `ONLINE=1` lists the subscriptions Google knows. With `ONLINE=1` the check also asks Google to read a purchase that cannot exist: "unknown purchase" means the service account may read purchases, a permission error means it may not. It changes nothing on Google and never prints a secret.
475
+
476
+ What it cannot try is acknowledging a purchase, which needs a real one: step 9 does. A healthy run:
477
+
478
+ ```
479
+ OK Android package name: com.example.app
480
+ OK Service account key (config.google.service_account_json): tillbell-api@my-project.iam.gserviceaccount.com
481
+ OK Engine mounted: /tillbell/purchases and /tillbell/notifications/google_play
482
+ OK Notification sender (config.google.pubsub_service_account_email): tillbell-push@my-project.iam.gserviceaccount.com
483
+ OK Notification audience: default: the notification URL as Rails sees it
484
+ OK Reading purchases: allowed (Google answered that the test token is unknown, as expected)
485
+ OK Subscription premium (base plan monthly): active
486
+ OK Subscription premium (base plan yearly): active
487
+
488
+ Everything is in place.
489
+ ```
490
+
491
+ Add `ONLINE=1` to your deploy checklist: it is the quickest way to learn that a key was revoked.
492
+
493
+ ### 8. Connect Google Play to the topic (Play Console)
494
+
495
+ *Monetize with Play* → *Monetization setup* → *Real-time developer notifications*: tick **Enable real-time notifications**, enter the full topic name from step 5, choose the notification content that covers **subscriptions and all voided purchases** (Tillbell handles both), save, then click **Send test message**.
496
+
497
+ The Play Console's success message only means the message reached **your topic**. Whether it reached your server is shown by your logs: `[Tillbell] Google Play notification test_notification`, and a 200 answer. A 401 there is the sender or the audience (see below). Without these notifications your app still sells subscriptions, but renewals and cancellations are never seen, and a purchase your server did not acknowledge is refunded by Google after three days.
498
+
499
+ ### 9. Test a purchase
500
+
501
+ 1. Play Console → *Settings* → *License testing*: add the Google accounts of your testers. They buy with test payment methods (pick the test card in the purchase sheet) and are not charged. Test subscriptions renew much faster than real ones, so you can watch a renewal and a cancellation: a monthly subscription renews every 5 minutes, a yearly one every 30, at most 6 times.
502
+ 2. Install your app on a device signed in with a tester account. License testers can install a build straight from their computer as long as the package name matches, or from the *Internal testing* opt-in link (a release on a testing track can take a few hours to reach testers).
503
+ 3. Open the paywall, buy, and watch `Pay::GooglePlay::Subscription` appear in your database. Then check that Google did not mark the purchase as unacknowledged: it should stay in your database as active after a few minutes.
504
+
505
+ ### If something is off
506
+
507
+ `bin/rails tillbell:check` names the cause of most problems. The rest:
508
+
509
+ | What you see | Likely cause | What to do |
510
+ |---|---|---|
511
+ | `not JSON`, or `private_key is missing or unreadable` | The key was pasted with a wrong indentation, or cut | Paste the whole key file again, with the `\|` and two spaces of indentation |
512
+ | `ONLINE=1`: *not authorized (401)* or *not allowed (403)* | Step 3 is missing or not applied yet, the permissions are not the two above, or (403) the API is not enabled in the project of the key | Check the invitation and the API (step 2.2), and wait up to a day |
513
+ | `ONLINE=1`: *not found (404)* | Wrong package name, or no such product in the Play Console | Compare with the Play Console |
514
+ | `ONLINE=1`: *key refused* | The key was deleted or rotated, or belongs to another project | Create a new JSON key and replace it |
515
+ | *Create new key* is greyed out or refused | An organization policy blocks service account keys (common on organizations created recently) | An organization administrator must allow key creation for this project |
516
+ | `Google was not asked` | An offline check failed | Fix the failures above it, then run again |
517
+ | The test message answers 401 in your logs | `pubsub_service_account_email` is not the account of step 4, or the audience differs | Compare the e-mails; if a proxy changes the URL Rails sees, set `config.google.pubsub_audience` to the same value as the subscription's *Audience* |
518
+ | Nothing arrives at your server | The endpoint URL is wrong, not public HTTPS, or the topic is not the one in the Play Console; or Pub/Sub's service agent cannot create tokens (step 6) | Check steps 5 to 8; the subscription's *Unacked messages* graph in the Cloud Console shows whether Google writes |
519
+ | The buy button stays disabled | The app does not register the `tillbell` component, or this is a browser | Use the app with [tillbell-android](https://github.com/pmakerhq/tillbell-android) |
520
+ | A purchase works, then Google refunds it after three days | Your server did not acknowledge it: the purchase endpoint failed | Look at your logs for the purchase request; run `bin/rails tillbell:check` |
284
521
 
285
522
  ## Security
286
523
 
@@ -294,6 +531,8 @@ Found a vulnerability? Please report it privately, as described in [SECURITY.md]
294
531
 
295
532
  ## Troubleshooting
296
533
 
534
+ Problems on Google's side (credentials, permissions, notifications) have their own table at the end of the [Google Play setup](#google-play-setup), and `bin/rails tillbell:check` names the cause of most of them.
535
+
297
536
  **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
537
 
299
538
  **`POST /tillbell/purchases` answers 401.** `current_owner` returned `nil`: the request is not signed in, or `current_owner` does not match your authentication.
@@ -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
@@ -7,7 +7,7 @@ module Tillbell
7
7
  class InstallGenerator < Rails::Generators::Base
8
8
  source_root File.expand_path("templates", __dir__)
9
9
 
10
- desc "Creates the Tillbell initializer, mounts the engine and copies the paywall Stimulus controller."
10
+ desc "Creates the Tillbell initializer, mounts the engine, copies the paywall Stimulus controller and prints the Google Play credentials template."
11
11
 
12
12
  def create_initializer
13
13
  copy_file "initializer.rb", "config/initializers/tillbell.rb"
@@ -29,6 +29,11 @@ module Tillbell
29
29
  "npm install @hotwired/hotwire-native-bridge", :yellow
30
30
  end
31
31
  end
32
+
33
+ # The part that happens on Google's side, with the credentials to fill in.
34
+ def show_next_steps
35
+ readme "next_steps.txt"
36
+ end
32
37
  end
33
38
  end
34
39
  end
@@ -1,5 +1,9 @@
1
1
  # Tillbell: Google Play subscriptions stored as Pay subscriptions.
2
2
  # See https://github.com/pmakerhq/tillbell-rails#configuration
3
+ #
4
+ # The two values below come from Google: the README's "Google Play setup" has the credentials
5
+ # template and every click. Once they are in your credentials, check everything with
6
+ # bin/rails tillbell:check (ONLINE=1 also asks Google)
3
7
  Tillbell.configure do |config|
4
8
  # Application id of the Android app.
5
9
  config.google.package_name = "com.example.app"
@@ -0,0 +1,25 @@
1
+
2
+ Tillbell is installed. What is left happens on Google's side.
3
+
4
+ 1. Put the two values Google gives you in your credentials
5
+ (bin/rails credentials:edit, or --environment production):
6
+
7
+ tillbell:
8
+ # The whole JSON key file of the service account that calls Google.
9
+ # Keep the "|" and the two-space indentation.
10
+ google_service_account: |
11
+ { "type": "service_account", "client_email": "<tillbell-api>@<project>.iam.gserviceaccount.com", ... }
12
+ # The service account Pub/Sub signs its notifications with.
13
+ pubsub_service_account_email: <tillbell-push>@<project>.iam.gserviceaccount.com
14
+
15
+ 2. Set config.google.package_name in config/initializers/tillbell.rb.
16
+
17
+ 3. Follow "Google Play setup" for every click (Play Console, service accounts, Pub/Sub). Allow
18
+ up to a day for Google to apply the permissions you grant:
19
+ https://github.com/pmakerhq/tillbell-rails#google-play-setup
20
+
21
+ 4. Check it:
22
+
23
+ bin/rails tillbell:check
24
+ ONLINE=1 PLANS=premium:monthly bin/rails tillbell:check # also asks Google
25
+
@@ -0,0 +1,17 @@
1
+ namespace :tillbell do
2
+ desc "Check the Tillbell configuration. ONLINE=1 also asks Google; PLANS=premium:monthly,premium:yearly checks those plans exist."
3
+ task check: :environment do
4
+ icons = { ok: "OK ", warn: "WARN", fail: "FAIL" }
5
+ online = !%w[0 false no].include?(ENV["ONLINE"].to_s.strip.downcase) && ENV["ONLINE"].present?
6
+ plans = ENV["PLANS"].to_s.split(",").map(&:strip).reject(&:empty?)
7
+ check = Tillbell::Check.new(online: online, plans: plans)
8
+
9
+ check.call.each do |result|
10
+ puts "#{icons.fetch(result.status)} #{result.label}#{": #{result.detail}" if result.detail}"
11
+ end
12
+ puts "\nGoogle was not asked: run again with ONLINE=1 to check the key, its rights and your subscriptions." unless online
13
+
14
+ abort "\nSomething will not work: see the README, \"Google Play setup\"." unless check.ok?
15
+ puts "\nEverything is in place."
16
+ end
17
+ end
@@ -0,0 +1,234 @@
1
+ require "openssl"
2
+
3
+ module Tillbell
4
+ # Checks that the configuration Tillbell needs is complete, and optionally that Google
5
+ # accepts it, so an app knows whether purchases and notifications can work before a
6
+ # customer tries. Run by +bin/rails tillbell:check+ (see the README, "Google Play setup").
7
+ #
8
+ # It changes nothing and never shows a secret: only the key names and the e-mail address
9
+ # of the service accounts.
10
+ #
11
+ # check = Tillbell::Check.new(online: true, plans: [ "premium:monthly", "premium:yearly" ])
12
+ # check.call.each { |result| puts "#{result.status} #{result.label} #{result.detail}" }
13
+ # check.ok? # false when something will not work
14
+ class Check
15
+ # One line of the report. +status+ is +:ok+, +:warn+ (worth a look) or +:fail+ (it will
16
+ # not work).
17
+ Result = Data.define(:status, :label, :detail)
18
+
19
+ # The shape of a Google Cloud service account e-mail address.
20
+ SERVICE_ACCOUNT_EMAIL = /\A[^@\s]+@[^@\s]+\.iam\.gserviceaccount\.com\z/
21
+
22
+ # [online] Also ask Google (Android Publisher API): the key, its rights and the
23
+ # subscriptions of the Play Console.
24
+ # [plans] Plans the app sells, as +"product_id:base_plan_id"+ strings or
25
+ # +{ product_id:, base_plan_id: }+ hashes. With +online+, each must exist in the
26
+ # Play Console with an active base plan. Without plans, +online+ lists what the
27
+ # Play Console has.
28
+ def initialize(online: false, plans: [])
29
+ @online = online
30
+ @plans = Array(plans).map { |plan| normalize(plan) }
31
+ @results = []
32
+ end
33
+
34
+ # Runs every check and returns the results, in the order to read them.
35
+ def call
36
+ @results = []
37
+ check_package_name
38
+ key_email = check_service_account_json
39
+ check_engine_mount
40
+ check_pubsub(key_email)
41
+ if @online && ok?
42
+ check_online
43
+ elsif @online
44
+ add :warn, "Google was not asked", "fix the failures above first, then run again"
45
+ end
46
+ @results
47
+ end
48
+
49
+ # True when nothing failed (warnings are fine).
50
+ def ok? = @results.none? { |result| result.status == :fail }
51
+
52
+ private
53
+
54
+ def google = Tillbell.google
55
+
56
+ def add(status, label, detail = nil)
57
+ @results << Result.new(status, label, detail)
58
+ nil
59
+ end
60
+
61
+ def normalize(plan)
62
+ return plan.symbolize_keys.slice(:product_id, :base_plan_id) if plan.respond_to?(:symbolize_keys)
63
+
64
+ product_id, base_plan_id = plan.to_s.split(":", 2)
65
+ { product_id: product_id, base_plan_id: base_plan_id.presence }
66
+ end
67
+
68
+ def check_package_name
69
+ if google.package_name.present?
70
+ add :ok, "Android package name", google.package_name
71
+ else
72
+ add :fail, "Android package name", "config.google.package_name is not set"
73
+ end
74
+ end
75
+
76
+ # Returns the e-mail address of the service account of the key, when it could be read.
77
+ def check_service_account_json
78
+ label = "Service account key (config.google.service_account_json)"
79
+ raw = google.service_account_json
80
+ return add(:fail, label, "not set: put the key file in your credentials") if raw.blank?
81
+
82
+ key = raw.is_a?(String) ? JSON.parse(raw) : raw
83
+ return add(:fail, label, "not a key file: expected a JSON object") unless key.respond_to?(:to_h) && !key.is_a?(Array)
84
+
85
+ key = key.to_h.stringify_keys
86
+ problems = []
87
+ problems << "\"type\" is not service_account" unless key["type"] == "service_account"
88
+ problems << "\"client_email\" is missing" if key["client_email"].blank?
89
+ problems << "\"private_key\" is missing or unreadable" unless private_key?(key["private_key"])
90
+ return add(:fail, label, problems.join(", ")) if problems.any?
91
+
92
+ add :ok, label, key["client_email"]
93
+ key["client_email"]
94
+ rescue JSON::ParserError
95
+ add :fail, label, "not JSON: paste the whole key file as Google gave it (mind the YAML indentation)"
96
+ end
97
+
98
+ # A key that parses: a mangled one (lost line breaks, truncated) is caught here, offline.
99
+ def private_key?(pem)
100
+ OpenSSL::PKey.read(pem.to_s)
101
+ true
102
+ rescue OpenSSL::PKey::PKeyError
103
+ false
104
+ end
105
+
106
+ def check_engine_mount
107
+ mount = Rails.application.routes.routes.find { |route| route.app.try(:app) == Tillbell::Engine }
108
+ if mount
109
+ add :ok, "Engine mounted", "#{mount.path.spec}/purchases and #{mount.path.spec}/notifications/google_play"
110
+ else
111
+ add :fail, "Engine mounted", "add `mount Tillbell::Engine, at: \"/tillbell\"` to config/routes.rb"
112
+ end
113
+ end
114
+
115
+ def check_pubsub(key_email)
116
+ label = "Notification sender (config.google.pubsub_service_account_email)"
117
+ email = google.pubsub_service_account_email
118
+ if email.blank?
119
+ add :fail, label, "not set: every notification is refused with 401, so renewals and cancellations are never synced"
120
+ elsif !SERVICE_ACCOUNT_EMAIL.match?(email)
121
+ add :fail, label, "#{email} does not look like a service account (name@project.iam.gserviceaccount.com)"
122
+ elsif email == key_email
123
+ add :warn, label, "#{email} also calls the Google Play API: prefer a dedicated account with no role"
124
+ else
125
+ add :ok, label, email
126
+ end
127
+
128
+ add :ok, "Notification audience", google.pubsub_audience.presence || "default: the notification URL as Rails sees it"
129
+ end
130
+
131
+ # Only reads: nothing is created, changed or acknowledged on Google.
132
+ def check_online
133
+ service = Tillbell::GooglePlay.service
134
+ return unless check_purchase_access(service)
135
+
136
+ @plans.any? ? @plans.each { |plan| check_plan(service, plan) } : list_subscriptions(service)
137
+ rescue StandardError => error
138
+ add :fail, "Google Play API access", describe(error)
139
+ end
140
+
141
+ # What the gem does all day is read a purchase, so ask for one that cannot exist. Google
142
+ # answers "unknown purchase" when the account may read purchases, and a permission error
143
+ # when it may not. Acknowledging and cancelling ("Manage orders and subscriptions")
144
+ # cannot be tried without a real purchase: step 9 of the README does. Returns whether
145
+ # reading is allowed.
146
+ def check_purchase_access(service)
147
+ service.get_purchase_subscriptionsv2(google.package_name, "tillbell-check")
148
+ add :ok, "Reading purchases", "allowed"
149
+ true
150
+ rescue Google::Apis::ClientError => error
151
+ if Tillbell::GooglePlay::INVALID_TOKEN_STATUSES.include?(error.status_code)
152
+ add :ok, "Reading purchases", "allowed (Google answered that the test token is unknown, as expected)"
153
+ true
154
+ else
155
+ add :fail, "Reading purchases", describe(error)
156
+ false
157
+ end
158
+ rescue Google::Apis::AuthorizationError, Signet::AuthorizationError => error
159
+ add :fail, "Reading purchases", describe(error)
160
+ false
161
+ end
162
+
163
+ def check_plan(service, plan)
164
+ label = "Subscription #{plan[:product_id]}#{" (base plan #{plan[:base_plan_id]})" if plan[:base_plan_id]}"
165
+ subscription = service.get_monetization_subscription(google.package_name, plan[:product_id])
166
+ base_plans = Array(subscription.base_plans)
167
+ base_plan = if plan[:base_plan_id]
168
+ base_plans.find { |candidate| candidate.base_plan_id == plan[:base_plan_id] }
169
+ else
170
+ base_plans.find { |candidate| candidate.state == "ACTIVE" } || base_plans.first
171
+ end
172
+
173
+ if base_plan.nil?
174
+ add :fail, label, "the product exists but not this base plan (it has: #{base_plans.map(&:base_plan_id).join(', ').presence || 'none'})"
175
+ elsif base_plan.state == "ACTIVE"
176
+ add :ok, label, "active"
177
+ else
178
+ add :fail, label, "base plan is #{base_plan.state}: activate it in the Play Console"
179
+ end
180
+ rescue Google::Apis::ClientError, Google::Apis::AuthorizationError => error
181
+ add :fail, label, describe(error, catalog: true)
182
+ end
183
+
184
+ def list_subscriptions(service)
185
+ subscriptions = []
186
+ token = nil
187
+ loop do
188
+ page = service.list_monetization_subscriptions(google.package_name, page_size: 1000, page_token: token)
189
+ subscriptions.concat(Array(page.subscriptions))
190
+ token = page.next_page_token.presence
191
+ break unless token
192
+ end
193
+ return add(:warn, "Subscriptions in the Play Console", "none found: create them under Monetize with Play, Products, Subscriptions") if subscriptions.empty?
194
+
195
+ subscriptions.each do |subscription|
196
+ plans = Array(subscription.base_plans).map { |base_plan| "#{base_plan.base_plan_id} (#{base_plan.state.to_s.downcase})" }
197
+ add :ok, "Subscription #{subscription.product_id}", plans.join(", ")
198
+ end
199
+ rescue Google::Apis::ClientError, Google::Apis::AuthorizationError => error
200
+ add :fail, "Subscriptions in the Play Console", describe(error, catalog: true)
201
+ end
202
+
203
+ # What to do about an error from Google. +catalog+: the error comes from reading the product
204
+ # catalog, which may need one more permission than reading purchases.
205
+ def describe(error, catalog: false)
206
+ case error
207
+ when Signet::AuthorizationError
208
+ "key refused by Google (revoked, deleted, or from another project): create a new JSON key"
209
+ when Google::Apis::AuthorizationError
210
+ "not authorized (401): invite the service account in the Play Console with the \"View financial data\" and " \
211
+ "\"Manage orders and subscriptions\" permissions, and allow up to a day for Google to apply them#{catalog_hint(catalog)}"
212
+ when Google::Apis::ClientError
213
+ client_error(error, catalog)
214
+ else
215
+ "#{error.class}: #{error.message}"
216
+ end
217
+ end
218
+
219
+ def client_error(error, catalog)
220
+ case error.status_code
221
+ when 403
222
+ "not allowed (403): enable the Google Play Android Developer API in the Cloud project of the key, and invite the " \
223
+ "service account in the Play Console with the \"View financial data\" and \"Manage orders and subscriptions\" " \
224
+ "permissions (Google can take up to a day to apply them)#{catalog_hint(catalog)}"
225
+ when 404 then "not found (404): wrong package name, or no such product in the Play Console"
226
+ else "Google answered #{error.status_code}: #{error.message}"
227
+ end
228
+ end
229
+
230
+ def catalog_hint(catalog)
231
+ catalog ? "; reading the product catalog may also need \"View app information (read-only)\"" : ""
232
+ end
233
+ end
234
+ 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.3.0"
3
3
  end
data/lib/tillbell.rb CHANGED
@@ -2,6 +2,7 @@ require "pay"
2
2
  require "tillbell/version"
3
3
  require "tillbell/engine"
4
4
  require "tillbell/google_play"
5
+ require "tillbell/check"
5
6
 
6
7
  # Tillbell adds Google Play subscriptions to Rails apps that use the Pay gem.
7
8
  #
@@ -13,8 +14,8 @@ require "tillbell/google_play"
13
14
  #
14
15
  # Tillbell.configure do |config|
15
16
  # 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"
17
+ # config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
18
+ # config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
18
19
  #
19
20
  # config.on(:subscription_created) { |pay_subscription| ... }
20
21
  # end
@@ -47,7 +48,25 @@ module Tillbell
47
48
  # +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
48
49
  mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
49
50
 
51
+ # What Hotwire Native writes in the user agent for the bridge components the app
52
+ # registered, e.g. "bridge-components: [haptic tillbell]". +tillbell+ must be in that
53
+ # list for the paywall to work.
54
+ NATIVE_USER_AGENT = /bridge-components: \[(?:[^\]]*\s)?tillbell[\s\]]/
55
+
50
56
  class << self
57
+ # True when +user_agent+ comes from a native app that registered the +tillbell+ bridge
58
+ # component (Android today, iOS later), so a Google Play purchase can start from the
59
+ # page. Use it to choose between the paywall and another way to pay, e.g. a web checkout:
60
+ #
61
+ # Tillbell.native?(request.user_agent)
62
+ #
63
+ # An app version without the component, and every browser, answer false. The check
64
+ # only picks which page to show: purchases are always verified with Google, so a
65
+ # forged user agent gains nothing.
66
+ def native?(user_agent)
67
+ NATIVE_USER_AGENT.match?(user_agent.to_s)
68
+ end
69
+
51
70
  # Yields the Tillbell module so settings can be assigned in a block.
52
71
  def configure
53
72
  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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pierre Filstroff
@@ -108,8 +108,11 @@ files:
108
108
  - contract/restore.request.json
109
109
  - lib/generators/tillbell/install/install_generator.rb
110
110
  - lib/generators/tillbell/install/templates/initializer.rb
111
+ - lib/generators/tillbell/install/templates/next_steps.txt
111
112
  - lib/generators/tillbell/install/templates/tillbell_controller.js
113
+ - lib/tasks/tillbell.rake
112
114
  - lib/tillbell.rb
115
+ - lib/tillbell/check.rb
113
116
  - lib/tillbell/engine.rb
114
117
  - lib/tillbell/google_play.rb
115
118
  - lib/tillbell/version.rb