tillbell 0.2.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +232 -16
- data/app/helpers/tillbell/paywall_builder.rb +13 -0
- data/app/models/pay/google_play/subscription.rb +86 -14
- data/config/locales/de.yml +15 -0
- data/config/locales/es.yml +15 -0
- data/config/locales/fr.yml +15 -0
- data/lib/generators/tillbell/install/install_generator.rb +6 -1
- data/lib/generators/tillbell/install/templates/initializer.rb +4 -0
- data/lib/generators/tillbell/install/templates/next_steps.txt +25 -0
- data/lib/tasks/tillbell.rake +17 -0
- data/lib/tillbell/check.rb +234 -0
- data/lib/tillbell/engine.rb +9 -0
- data/lib/tillbell/google_play.rb +4 -0
- data/lib/tillbell/owner.rb +61 -0
- data/lib/tillbell/version.rb +1 -1
- data/lib/tillbell.rb +62 -4
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e2e56c0874935103736d6f90d868ec525033d4abe32437269b39ea2772d224b
|
|
4
|
+
data.tar.gz: dde35b5c71bee8fee12aef4ba5fd99c5870d034da7150a8fbcaea55d4dfc5a6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c951a6879b9603ae4202f731714c2568381f7761d5a86b96fe7566df3e79e7d9c334faf82936582e7f27bb9151f4d614e875c65f725fc99f72f9b133296e3e0d
|
|
7
|
+
data.tar.gz: fe33d1a43adf91d7a8efdfdad0bbd5acba86536cdcaab1fdf265f6b22491eb3963f9e1029094adc639bf40add424afa360ac6b7666deeda9059a64f43b0ea90e
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,49 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-10-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Tillbell::Owner`, to include in the model that declares `pay_customer`: `google_play_subscription`,
|
|
14
|
+
`google_play_on_hold?`, `current_subscription` (the best subscription across processors),
|
|
15
|
+
`billing_subscriptions` and `cancel_billing_subscriptions!` (for closing or deleting an account).
|
|
16
|
+
- `billing?` on every Pay subscription: still billed, retried after a failed payment, or paused.
|
|
17
|
+
- Events `subscription_renewed`, `payment_failed`, `payment_recovered` and `plan_changed`. They are
|
|
18
|
+
emitted next to the existing events, which are unchanged.
|
|
19
|
+
- `config.plans`, `Tillbell.plan`, `Tillbell.replacement_mode(from:, to:)` and `subscription.plan`:
|
|
20
|
+
a catalog of the plans you sell. `tillbell:check` verifies them without `PLANS`.
|
|
21
|
+
- `paywall.plan_change(to:, from:)`: the option of a plan change, with the replacement mode chosen
|
|
22
|
+
from the order of the catalog.
|
|
23
|
+
- `subscription.replaced_subscription` and `subscription.token_digest` (a purchase identifier that
|
|
24
|
+
is safe to send to analytics).
|
|
25
|
+
- Paywall texts in French, Spanish and German (formal address). Their locales become available
|
|
26
|
+
in the host app unless it sets `config.i18n.available_locales`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- `cancel` and `cancel_now!` raise a `Tillbell::Error` (a `Pay::Error`, with Google's error as its
|
|
31
|
+
`cause`) instead of a `Google::Apis::Error` or a `Signet::AuthorizationError`, so a host app
|
|
32
|
+
rescues Pay's error only. Code that rescued the Google errors from `cancel` must rescue
|
|
33
|
+
`Pay::Error` too.
|
|
34
|
+
|
|
35
|
+
## [0.3.0] - 2026-10-30
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `bin/rails tillbell:check` (`Tillbell::Check`): checks the package name, the service account
|
|
40
|
+
key, that the engine is mounted and the notification sender, without printing a secret.
|
|
41
|
+
`ONLINE=1` also asks Google (the key, its rights, the subscriptions of the Play Console);
|
|
42
|
+
`PLANS=premium:monthly,premium:yearly` checks the plans the app sells exist and are active.
|
|
43
|
+
- The install generator prints the credentials template and the next steps.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- README, "Google Play setup": rewritten for someone who has never touched Google Play's
|
|
48
|
+
consoles. What Google needs and why, a credentials template to fill in, a checklist, every
|
|
49
|
+
step in the Play Console and the Cloud Console, the check, a test purchase and a
|
|
50
|
+
troubleshooting table.
|
|
51
|
+
|
|
9
52
|
## [0.2.0] - 2026-10-30
|
|
10
53
|
|
|
11
54
|
### Added
|
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 =
|
|
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
|
|
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 =
|
|
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. |
|
|
@@ -174,6 +178,7 @@ end
|
|
|
174
178
|
| `google.pubsub_audience` | endpoint URL | Audience of the Pub/Sub OIDC token. Set it when a proxy changes the URL Rails sees. |
|
|
175
179
|
| `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
180
|
| `parent_controller` | `"ApplicationController"` | Controller the purchase endpoint inherits from, to reuse your authentication, CSRF protection and error handling. |
|
|
181
|
+
| `plans` | `[]` | The plans you sell, lowest first: `[{ name: :solo, product_id: "app_solo", base_plan_id: "monthly" }, ...]`. `tillbell:check` verifies them, `subscription.plan` finds the one a subscription is on, and `Tillbell.replacement_mode` and `paywall.plan_change` use their order to tell an upgrade from a downgrade. |
|
|
177
182
|
| `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
183
|
|
|
179
184
|
## The paywall
|
|
@@ -195,7 +200,7 @@ end
|
|
|
195
200
|
<% end %>
|
|
196
201
|
```
|
|
197
202
|
|
|
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.
|
|
203
|
+
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"`). English, French, Spanish and German are included (their locales become available in an app that does not set `config.i18n.available_locales`). A locale without translations falls back to English.
|
|
199
204
|
|
|
200
205
|
| Builder method | Renders |
|
|
201
206
|
|---|---|
|
|
@@ -293,6 +298,42 @@ A `Pay::GooglePlay::Subscription` stores:
|
|
|
293
298
|
| `manage_url` | The Play Store page where the customer manages this subscription (update the payment method, cancel, resubscribe). Same page as `paywall.manage`, for web pages and emails. `Tillbell::GooglePlay.manage_url(product_id)` gives it for any product, and without one the list of all subscriptions. |
|
|
294
299
|
| `in_grace_period?`, `on_hold?`, `revoked?` | The Google states that Pay statuses merge. |
|
|
295
300
|
|
|
301
|
+
**Plans.** Declare what you sell in `config.plans` and Tillbell knows the rest:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
config.plans = [
|
|
305
|
+
{ name: :solo, product_id: "app_solo", base_plan_id: "monthly" },
|
|
306
|
+
{ name: :family, product_id: "app_family", base_plan_id: "monthly" }
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
subscription.plan # => #<data Tillbell::Plan name=:family, ...>, or nil if it is not in the catalog
|
|
310
|
+
Tillbell.plan(:family) # by name, or Tillbell.plan(product_id: "app_family", base_plan_id: "monthly")
|
|
311
|
+
Tillbell.replacement_mode(from: :solo, to: :family) # => "CHARGE_PRORATED_PRICE" (upgrade); a downgrade gives "DEFERRED"
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
A plan change from the settings page is then one option in a paywall:
|
|
315
|
+
|
|
316
|
+
```erb
|
|
317
|
+
<%= tillbell_paywall(success_url: settings_path) do |paywall| %>
|
|
318
|
+
<%= paywall.plan_change(to: :family, from: current_user.google_play_subscription) { "Switch to Family" } %>
|
|
319
|
+
<%= paywall.submit %>
|
|
320
|
+
<%= paywall.status %>
|
|
321
|
+
<%= paywall.fallback %>
|
|
322
|
+
<% end %>
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
`token_digest` is a stable identifier of a purchase that is safe to send to an analytics or advertising service (an event id that deduplicates); the purchase token itself must never leave your server.
|
|
326
|
+
|
|
327
|
+
**Owners with several processors.** `include Tillbell::Owner` in the model that declares `pay_customer`:
|
|
328
|
+
|
|
329
|
+
| Method | Returns |
|
|
330
|
+
|---|---|
|
|
331
|
+
| `google_play_subscription` | The Google Play subscription, whatever the default processor. |
|
|
332
|
+
| `google_play_on_hold?` | True when Google suspended the subscription for a failed payment: send the customer to `manage_url` rather than to a second subscription, which would bill twice if Google's retry succeeds. |
|
|
333
|
+
| `current_subscription` | The subscription to show and decide access from: the one that renews, then one that is canceled but running, then one whose payment is retried or that is paused; the most recent breaks a tie. |
|
|
334
|
+
| `billing_subscriptions` | What still bills or resumes by itself (`subscription.billing?`), Google Play first. |
|
|
335
|
+
| `cancel_billing_subscriptions!` | Cancels them: call it before closing or deleting an account. |
|
|
336
|
+
|
|
296
337
|
A customer can have a Stripe subscription and a Google Play one (a web customer who later subscribes in the app, or the reverse). `Pay::GooglePlay::Customer` is never the owner's default payment processor, so `owner.payment_processor` does not see Google Play subscriptions: look at `owner.pay_subscriptions`, or at the Google Play customer (`owner.pay_customers.google_play.first`), to decide who has access.
|
|
297
338
|
|
|
298
339
|
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`).
|
|
@@ -302,10 +343,10 @@ Pay only knows its own processors, so Tillbell adds `google_play?` to customers
|
|
|
302
343
|
**Closing or deleting an account.** Cancel what still bills before you close the account, whatever the processor, and before changing anything in your database: an error raised after the account is closed leaves the subscription billing a closed account. Pay's own hook when an owner is destroyed only cancels `active` subscriptions. Google keeps retrying a held payment (`past_due`) and resumes a paused subscription by itself, so include both:
|
|
303
344
|
|
|
304
345
|
```ruby
|
|
305
|
-
user.
|
|
346
|
+
user.cancel_billing_subscriptions! # with Tillbell::Owner: what `billing?` says still bills, Google Play first
|
|
306
347
|
```
|
|
307
348
|
|
|
308
|
-
|
|
349
|
+
`billing?` is true for an active subscription (a canceled one that has not ended included), one whose payment is retried (`past_due`, `unpaid`) and a paused one, which resumes by itself, a Stripe pause (status `active`) included. A Google Play purchase still pending (a cash payment) is not: Google cannot cancel it until it completes. Google can fail (API error, revoked service account key): `cancel` raises a `Tillbell::Error`, a `Pay::Error` whose `cause` is Google's error, so rescuing `Pay::Error` next to your other processors' errors is enough. Tell the customer to try again.
|
|
309
350
|
|
|
310
351
|
## Events
|
|
311
352
|
|
|
@@ -313,6 +354,10 @@ Google can fail (API error, revoked service account key), so rescue `Pay::Error`
|
|
|
313
354
|
Tillbell.configure do |config|
|
|
314
355
|
config.on(:subscription_created) { |subscription| WelcomeMailer.with(user: subscription.owner).premium.deliver_later }
|
|
315
356
|
config.on(:subscription_updated) { |subscription| } # renewal, grace period, hold, recovery, plan change
|
|
357
|
+
config.on(:subscription_renewed) { |subscription| } # a new period started (also fires subscription_updated)
|
|
358
|
+
config.on(:payment_failed) { |subscription| } # a payment failed: the grace period or the hold started (also fires subscription_updated)
|
|
359
|
+
config.on(:payment_recovered) { |subscription| } # the payment went through after a failure
|
|
360
|
+
config.on(:plan_changed) { |subscription| } # the customer is now on another product or base plan; subscription.replaced_subscription is the previous purchase, if any
|
|
316
361
|
config.on(:subscription_canceled) { |subscription| } # will not renew; access until ends_at
|
|
317
362
|
config.on(:subscription_expired) { |subscription| }
|
|
318
363
|
config.on(:subscription_revoked) { |subscription| } # refunded or charged back
|
|
@@ -320,7 +365,7 @@ Tillbell.configure do |config|
|
|
|
320
365
|
end
|
|
321
366
|
```
|
|
322
367
|
|
|
323
|
-
A plan change fires `subscription_created` for the new purchase (`replacement?` is true)
|
|
368
|
+
A plan change fires `subscription_created` for the new purchase (`replacement?` is true), `subscription_expired` for the old one (`replaced?` is true) and then `plan_changed` once. `subscription_updated` fires for every change, a renewal included; `subscription_renewed`, `payment_failed`, `payment_recovered` and `plan_changed` say which one happened (`payment_failed` fires on the first failure only, not again when the grace period turns into a hold), so you rarely need to filter it yourself. If you do, for example in a Slack or analytics subscriber:
|
|
324
369
|
|
|
325
370
|
```ruby
|
|
326
371
|
Tillbell.on(:subscription_updated) do |subscription|
|
|
@@ -337,14 +382,183 @@ Events go through Pay's webhook delegator, so `Pay::Webhooks.delegator.subscribe
|
|
|
337
382
|
|
|
338
383
|
## Google Play setup
|
|
339
384
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
385
|
+
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:
|
|
386
|
+
|
|
387
|
+
| Website | What lives there | Called here |
|
|
388
|
+
|---|---|---|
|
|
389
|
+
| [Google Play Console](https://play.google.com/console) | Your app, your subscriptions, who may access them | **Play Console** |
|
|
390
|
+
| [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** |
|
|
391
|
+
|
|
392
|
+
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.
|
|
393
|
+
|
|
394
|
+
> [!NOTE]
|
|
395
|
+
> 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.
|
|
396
|
+
|
|
397
|
+
### Before you start
|
|
398
|
+
|
|
399
|
+
- A Google Play developer account, with your app created in the Play Console.
|
|
400
|
+
- A payments profile (merchant account) linked to the developer account: Google asks for it when you create a paid product.
|
|
401
|
+
- **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.
|
|
402
|
+
- Your Rails app deployed on a public HTTPS address (Google must be able to call it).
|
|
403
|
+
|
|
404
|
+
### The template to fill in
|
|
405
|
+
|
|
406
|
+
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 `>`:
|
|
407
|
+
|
|
408
|
+
```yaml
|
|
409
|
+
tillbell:
|
|
410
|
+
# Step 2: the whole JSON key file of the service account that calls Google.
|
|
411
|
+
# Keep the "|" and the two-space indentation, or the key is unreadable.
|
|
412
|
+
google_service_account: |
|
|
413
|
+
{
|
|
414
|
+
"type": "service_account",
|
|
415
|
+
"project_id": "<your-cloud-project>",
|
|
416
|
+
"private_key_id": "...",
|
|
417
|
+
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
|
418
|
+
"client_email": "<tillbell-api>@<your-cloud-project>.iam.gserviceaccount.com",
|
|
419
|
+
"client_id": "...",
|
|
420
|
+
"token_uri": "https://oauth2.googleapis.com/token"
|
|
421
|
+
}
|
|
422
|
+
# Step 4: the e-mail address of the service account Pub/Sub signs its notifications with.
|
|
423
|
+
pubsub_service_account_email: <tillbell-push>@<your-cloud-project>.iam.gserviceaccount.com
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
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)).
|
|
427
|
+
|
|
428
|
+
`rails generate tillbell:install` prints this template when it finishes.
|
|
429
|
+
|
|
430
|
+
### The checklist
|
|
431
|
+
|
|
432
|
+
Copy it into your tracker and tick it as you go. Each step is explained below.
|
|
433
|
+
|
|
434
|
+
```markdown
|
|
435
|
+
- [ ] 0. Developer account, app created, payments profile, a build with the Billing Library on a testing track
|
|
436
|
+
- [ ] 1. Subscriptions created in the Play Console, base plan active (note the product and base plan ids)
|
|
437
|
+
- [ ] 2. Service account "tillbell-api" created, API enabled, JSON key downloaded
|
|
438
|
+
- [ ] 3. That service account invited in the Play Console with the two permissions (then wait, up to a day)
|
|
439
|
+
- [ ] 4. Service account "tillbell-push" created (no role, no key), e-mail copied
|
|
440
|
+
- [ ] 5. Pub/Sub topic created, Google Play allowed to publish to it
|
|
441
|
+
- [ ] 6. Push subscription created: your URL, authentication on, "tillbell-push"
|
|
442
|
+
- [ ] 7. Credentials filled in and deployed, `bin/rails tillbell:check` passes
|
|
443
|
+
- [ ] 8. Topic connected in the Play Console, test message sent and received
|
|
444
|
+
- [ ] 9. License testers added, test purchase made
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### 1. Create your subscriptions (Play Console)
|
|
448
|
+
|
|
449
|
+
*Monetize with Play* → *Products* → *Subscriptions* → *Create subscription*.
|
|
450
|
+
|
|
451
|
+
- The **product ID** names the subscription (`premium`). You pass it as `product_id:` to `plan_option`.
|
|
452
|
+
- 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.
|
|
453
|
+
- Neither ID can be changed later, so pick them carefully.
|
|
454
|
+
|
|
455
|
+
### 2. Create the service account that calls Google (Cloud Console)
|
|
456
|
+
|
|
457
|
+
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.
|
|
458
|
+
|
|
459
|
+
1. Pick (or create) a Cloud project. Any project works: what ties the service account to your app is the invitation of step 3.
|
|
460
|
+
2. *APIs & Services* → *Library* → search **Google Play Android Developer API** → *Enable*.
|
|
461
|
+
3. *IAM & Admin* → *Service Accounts* → *Create service account*. Name it `tillbell-api`. Give it **no role** here.
|
|
462
|
+
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.
|
|
463
|
+
|
|
464
|
+
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).
|
|
465
|
+
|
|
466
|
+
### 3. Let that service account into the Play Console
|
|
467
|
+
|
|
468
|
+
The Cloud Console knows the account exists. The Play Console must also allow it.
|
|
469
|
+
|
|
470
|
+
*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:
|
|
471
|
+
|
|
472
|
+
- **View financial data** (the account-level tab calls it *View financial data, orders and cancellation survey responses*);
|
|
473
|
+
- **Manage orders and subscriptions**.
|
|
474
|
+
|
|
475
|
+
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.
|
|
476
|
+
|
|
477
|
+
### 4. Create the account that signs notifications (Cloud Console)
|
|
478
|
+
|
|
479
|
+
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.
|
|
480
|
+
|
|
481
|
+
*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.
|
|
482
|
+
|
|
483
|
+
### 5. Create the Pub/Sub topic (Cloud Console)
|
|
484
|
+
|
|
485
|
+
A *topic* is the mailbox Google Play drops notifications in.
|
|
486
|
+
|
|
487
|
+
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`.
|
|
488
|
+
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.
|
|
489
|
+
|
|
490
|
+
### 6. Make the topic call your server (Cloud Console)
|
|
491
|
+
|
|
492
|
+
A *push subscription* forwards each message to a URL.
|
|
493
|
+
|
|
494
|
+
Open the topic → *Create subscription*:
|
|
495
|
+
|
|
496
|
+
- **Delivery type**: *Push*.
|
|
497
|
+
- **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`).
|
|
498
|
+
- **Enable authentication**: ticked, with the service account of step 4 (`tillbell-push`).
|
|
499
|
+
- **Audience**: leave empty. It then defaults to the endpoint URL, which is what Tillbell expects.
|
|
500
|
+
|
|
501
|
+
Two permissions can get in the way:
|
|
502
|
+
|
|
503
|
+
- If Google refuses to create the subscription with authentication, your own Google account needs the **Service Account User** role on `tillbell-push`.
|
|
504
|
+
- 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.
|
|
505
|
+
|
|
506
|
+
### 7. Fill in the credentials, deploy and check
|
|
507
|
+
|
|
508
|
+
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.
|
|
509
|
+
|
|
510
|
+
```sh
|
|
511
|
+
bin/rails tillbell:check # reads your configuration, asks nobody
|
|
512
|
+
ONLINE=1 PLANS=premium:monthly,premium:yearly bin/rails tillbell:check # also asks Google
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`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.
|
|
516
|
+
|
|
517
|
+
What it cannot try is acknowledging a purchase, which needs a real one: step 9 does. A healthy run:
|
|
518
|
+
|
|
519
|
+
```
|
|
520
|
+
OK Android package name: com.example.app
|
|
521
|
+
OK Service account key (config.google.service_account_json): tillbell-api@my-project.iam.gserviceaccount.com
|
|
522
|
+
OK Engine mounted: /tillbell/purchases and /tillbell/notifications/google_play
|
|
523
|
+
OK Notification sender (config.google.pubsub_service_account_email): tillbell-push@my-project.iam.gserviceaccount.com
|
|
524
|
+
OK Notification audience: default: the notification URL as Rails sees it
|
|
525
|
+
OK Reading purchases: allowed (Google answered that the test token is unknown, as expected)
|
|
526
|
+
OK Subscription premium (base plan monthly): active
|
|
527
|
+
OK Subscription premium (base plan yearly): active
|
|
528
|
+
|
|
529
|
+
Everything is in place.
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Add `ONLINE=1` to your deploy checklist: it is the quickest way to learn that a key was revoked.
|
|
533
|
+
|
|
534
|
+
### 8. Connect Google Play to the topic (Play Console)
|
|
535
|
+
|
|
536
|
+
*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**.
|
|
537
|
+
|
|
538
|
+
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.
|
|
539
|
+
|
|
540
|
+
### 9. Test a purchase
|
|
541
|
+
|
|
542
|
+
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.
|
|
543
|
+
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).
|
|
544
|
+
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.
|
|
545
|
+
|
|
546
|
+
### If something is off
|
|
547
|
+
|
|
548
|
+
`bin/rails tillbell:check` names the cause of most problems. The rest:
|
|
549
|
+
|
|
550
|
+
| What you see | Likely cause | What to do |
|
|
551
|
+
|---|---|---|
|
|
552
|
+
| `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 |
|
|
553
|
+
| `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 |
|
|
554
|
+
| `ONLINE=1`: *not found (404)* | Wrong package name, or no such product in the Play Console | Compare with the Play Console |
|
|
555
|
+
| `ONLINE=1`: *key refused* | The key was deleted or rotated, or belongs to another project | Create a new JSON key and replace it |
|
|
556
|
+
| *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 |
|
|
557
|
+
| `Google was not asked` | An offline check failed | Fix the failures above it, then run again |
|
|
558
|
+
| 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* |
|
|
559
|
+
| 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 |
|
|
560
|
+
| 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) |
|
|
561
|
+
| 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` |
|
|
348
562
|
|
|
349
563
|
## Security
|
|
350
564
|
|
|
@@ -358,6 +572,8 @@ Found a vulnerability? Please report it privately, as described in [SECURITY.md]
|
|
|
358
572
|
|
|
359
573
|
## Troubleshooting
|
|
360
574
|
|
|
575
|
+
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.
|
|
576
|
+
|
|
361
577
|
**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.
|
|
362
578
|
|
|
363
579
|
**`POST /tillbell/purchases` answers 401.** `current_owner` returned `nil`: the request is not signed in, or `current_owner` does not match your authentication.
|
|
@@ -26,6 +26,19 @@ module Tillbell
|
|
|
26
26
|
tag.label(**html_options) { radio + @template.capture(&block) }
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# The plan option for moving from the Google Play subscription +from+ to the plan +to+ of
|
|
30
|
+
# +Tillbell.plans+ (a name or a plan), checked, with the replacement mode Google needs:
|
|
31
|
+
# prorated for an upgrade, deferred to the next renewal for a downgrade (see
|
|
32
|
+
# +Tillbell.replacement_mode+). Use it in a paywall that offers this one plan.
|
|
33
|
+
def plan_change(to:, from:, **html_options, &block)
|
|
34
|
+
raise ArgumentError, "plan_change needs the current Google Play subscription (from:)" if from.nil?
|
|
35
|
+
|
|
36
|
+
current = from.plan || raise(ArgumentError, "the current subscription (#{from.processor_plan}:#{from.base_plan_id}) is not in Tillbell.plans")
|
|
37
|
+
target = to.is_a?(Tillbell::Plan) ? to : Tillbell.plan(to) || raise(ArgumentError, "#{to.inspect} is not in Tillbell.plans")
|
|
38
|
+
plan_option(product_id: target.product_id, base_plan_id: target.base_plan_id, checked: true, replacing: from,
|
|
39
|
+
replacement_mode: Tillbell.replacement_mode(from: current, to: target), **html_options, &block)
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
# Placeholder for the localized price, filled in from Google Play. Use it inside a
|
|
30
43
|
# +plan_option+ block.
|
|
31
44
|
def price(placeholder = "", **html_options)
|
|
@@ -40,6 +40,11 @@ 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
|
+
ACTIVE_STATE = "SUBSCRIPTION_STATE_ACTIVE".freeze
|
|
44
|
+
|
|
45
|
+
# Google states of a payment that failed and is being retried.
|
|
46
|
+
PAYMENT_PROBLEM_STATES = %w[SUBSCRIPTION_STATE_IN_GRACE_PERIOD SUBSCRIPTION_STATE_ON_HOLD].freeze
|
|
47
|
+
|
|
43
48
|
store_accessor :data, :subscription_state, :base_plan_id, :auto_renewing, :revoked_at, :replaced_by
|
|
44
49
|
|
|
45
50
|
class << self
|
|
@@ -62,10 +67,13 @@ module Pay
|
|
|
62
67
|
return
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
subscription,
|
|
66
|
-
Tillbell.instrument(event, subscription)
|
|
70
|
+
subscription, events = save_from_google(pay_customer, purchase_token, attributes_from(object, revoked: revoked))
|
|
71
|
+
events.each { |event| Tillbell.instrument(event, subscription) }
|
|
67
72
|
|
|
68
|
-
retire(object[:linked_purchase_token], replaced_by: purchase_token)
|
|
73
|
+
retire(object[:linked_purchase_token], replaced_by: purchase_token).each do |old, expired|
|
|
74
|
+
Tillbell.instrument(:subscription_expired, old) if expired
|
|
75
|
+
Tillbell.instrument(:plan_changed, subscription) if [ old.processor_plan, old.base_plan_id ] != [ subscription.processor_plan, subscription.base_plan_id ]
|
|
76
|
+
end
|
|
69
77
|
acknowledge(subscription, object)
|
|
70
78
|
subscription
|
|
71
79
|
end
|
|
@@ -95,7 +103,7 @@ module Pay
|
|
|
95
103
|
|
|
96
104
|
private
|
|
97
105
|
# Saves the attributes under a row lock and returns the subscription with the
|
|
98
|
-
#
|
|
106
|
+
# events its changes call for. Concurrent syncs of the same token (purchase
|
|
99
107
|
# request and notification) serialize on the lock, so only one emits the event.
|
|
100
108
|
def save_from_google(pay_customer, purchase_token, attributes)
|
|
101
109
|
attempts = 0
|
|
@@ -110,9 +118,9 @@ module Pay
|
|
|
110
118
|
# still report it as active until the renewal) must not bring it back.
|
|
111
119
|
subscription.assign_attributes(subscription.revoked? || subscription.replaced? ? attributes.except(:status, :ends_at, :revoked_at) : attributes)
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
events = subscription.events_for_changes
|
|
114
122
|
subscription.save!
|
|
115
|
-
[ subscription,
|
|
123
|
+
[ subscription, events ]
|
|
116
124
|
end
|
|
117
125
|
rescue ActiveRecord::RecordNotUnique
|
|
118
126
|
# Another process inserted the row first; a second attempt finds it and locks it.
|
|
@@ -125,12 +133,21 @@ module Pay
|
|
|
125
133
|
# for a canceled subscription) gets a new purchase token that points to the old one.
|
|
126
134
|
# The old subscription ends now, remembers the token that replaced it, and the new
|
|
127
135
|
# one carries the access.
|
|
136
|
+
#
|
|
137
|
+
# Returns +[subscription, expired]+ pairs for the subscriptions it marked as replaced,
|
|
138
|
+
# +expired+ telling whether this call ended them: a notification about the old token
|
|
139
|
+
# may have ended it first (and emitted +subscription_expired+, before the replacement
|
|
140
|
+
# was known). Rows are locked and skipped once replaced, so two syncs of the same new
|
|
141
|
+
# token (purchase request and notification) retire the old one, and report it, once.
|
|
128
142
|
def retire(linked_purchase_token, replaced_by:)
|
|
129
|
-
return if linked_purchase_token.blank?
|
|
143
|
+
return [] if linked_purchase_token.blank?
|
|
130
144
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
transaction(requires_new: true) do
|
|
146
|
+
where(processor_id: linked_purchase_token).lock.reject(&:replaced?).map do |subscription|
|
|
147
|
+
expired = subscription.status != "canceled"
|
|
148
|
+
subscription.update!(status: "canceled", ends_at: expired ? Time.current : subscription.ends_at, replaced_by: replaced_by)
|
|
149
|
+
[ subscription, expired ]
|
|
150
|
+
end
|
|
134
151
|
end
|
|
135
152
|
end
|
|
136
153
|
|
|
@@ -154,6 +171,15 @@ module Pay
|
|
|
154
171
|
:subscription_updated
|
|
155
172
|
end
|
|
156
173
|
|
|
174
|
+
# Every event the pending changes call for, the generic one first (see
|
|
175
|
+
# +event_for_changes+) then the detailed ones the host app would otherwise have to
|
|
176
|
+
# deduce from +saved_changes+. Called before saving.
|
|
177
|
+
def events_for_changes
|
|
178
|
+
events = [ event_for_changes ]
|
|
179
|
+
events.push(*detail_events) if persisted? && !revoked? && !replaced?
|
|
180
|
+
events.compact
|
|
181
|
+
end
|
|
182
|
+
|
|
157
183
|
# True while Google retries a failed payment and the customer keeps access.
|
|
158
184
|
def in_grace_period?
|
|
159
185
|
subscription_state == "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
|
|
@@ -233,6 +259,24 @@ module Pay
|
|
|
233
259
|
deferred_product_id.present?
|
|
234
260
|
end
|
|
235
261
|
|
|
262
|
+
# The subscription this purchase replaces (see +replacement?+), or nil.
|
|
263
|
+
def replaced_subscription
|
|
264
|
+
self.class.find_by(processor_id: linked_purchase_token) if replacement?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The plan of +Tillbell.plans+ this subscription is on, or nil when it is not in the
|
|
268
|
+
# catalog.
|
|
269
|
+
def plan
|
|
270
|
+
Tillbell.plan(product_id: processor_plan, base_plan_id: base_plan_id)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A stable identifier of the purchase that is safe to send to a third party (an analytics
|
|
274
|
+
# or advertising event id): the purchase token itself grants access to the subscription
|
|
275
|
+
# and must never leave the server.
|
|
276
|
+
def token_digest
|
|
277
|
+
Digest::SHA256.hexdigest(processor_id).first(16)
|
|
278
|
+
end
|
|
279
|
+
|
|
236
280
|
# Stops the renewal with Google; access continues until the end of the period.
|
|
237
281
|
# A subscription that already stopped renewing (or ended) has nothing to stop: it is only
|
|
238
282
|
# refreshed, or left alone. Deleting an owner (Pay calls +cancel_now!+ on every
|
|
@@ -241,15 +285,16 @@ module Pay
|
|
|
241
285
|
# Google answers 410 for a purchase token that expired more than 60 days ago. A
|
|
242
286
|
# subscription still stored as active (a missed notification) then has nothing left to
|
|
243
287
|
# 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
|
|
245
|
-
#
|
|
288
|
+
# error (a 404 or 400 from a wrong package name, a 403, a revoked service account key) is
|
|
289
|
+
# raised as a +Tillbell::Error+ (a +Pay::Error+), whose +cause+ is Google's error: the
|
|
290
|
+
# subscription may still be billing, and the host app only has to rescue Pay's error.
|
|
246
291
|
def cancel(**)
|
|
247
292
|
return self if ended?
|
|
248
293
|
|
|
249
294
|
Tillbell::GooglePlay.cancel(processor_plan, processor_id) unless renewal_stopped?
|
|
250
295
|
sync!
|
|
251
|
-
rescue
|
|
252
|
-
raise unless error.status_code == 410
|
|
296
|
+
rescue *Tillbell::GooglePlay::API_ERRORS => error
|
|
297
|
+
raise Tillbell::Error, "Google Play refused to stop the renewal: #{error.message}" unless error.try(:status_code) == 410
|
|
253
298
|
|
|
254
299
|
update!(status: "canceled", ends_at: [ current_period_end, Time.current ].compact.min, auto_renewing: false)
|
|
255
300
|
Tillbell.instrument(:subscription_expired, self)
|
|
@@ -272,6 +317,33 @@ module Pay
|
|
|
272
317
|
end
|
|
273
318
|
|
|
274
319
|
private
|
|
320
|
+
# Events that say what happened to an existing subscription, deduced from its pending
|
|
321
|
+
# changes: +:plan_changed+ (the product or base plan changed on the same purchase, e.g. a
|
|
322
|
+
# deferred downgrade that took effect), +:subscription_renewed+, +:payment_failed+ (the
|
|
323
|
+
# first failure only: the move from the grace period to the hold is not a second one) and
|
|
324
|
+
# +:payment_recovered+. A replacement purchase is announced by +Subscription.sync+.
|
|
325
|
+
def detail_events
|
|
326
|
+
events = []
|
|
327
|
+
events << :plan_changed if processor_plan_changed? || base_plan_id_changed?
|
|
328
|
+
events << :subscription_renewed if renewed?
|
|
329
|
+
events << :payment_failed if PAYMENT_PROBLEM_STATES.include?(subscription_state) && !PAYMENT_PROBLEM_STATES.include?(previous_state)
|
|
330
|
+
events << :payment_recovered if subscription_state == ACTIVE_STATE && PAYMENT_PROBLEM_STATES.include?(previous_state)
|
|
331
|
+
events
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# A new period started: the expiry moved on while the subscription was in good standing
|
|
335
|
+
# or recovering. Not the first activation of a pending purchase.
|
|
336
|
+
def renewed?
|
|
337
|
+
subscription_state == ACTIVE_STATE && current_period_end_changed? && current_period_end_was.present? &&
|
|
338
|
+
[ ACTIVE_STATE, *PAYMENT_PROBLEM_STATES ].include?(previous_state)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The state before the pending changes. The store accessor's +_was+ answers nil for a
|
|
342
|
+
# state that did not change.
|
|
343
|
+
def previous_state
|
|
344
|
+
subscription_state_changed? ? subscription_state_was : subscription_state
|
|
345
|
+
end
|
|
346
|
+
|
|
275
347
|
# True when Google already reports the subscription as canceled or expired. The state,
|
|
276
348
|
# not +auto_renewing+, which comes from the entitlement about to end and is false while a
|
|
277
349
|
# deferred plan change waits for the renewal, though the subscription will renew.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Paywall-Texte auf Deutsch. Jeder Text lässt sich in den Sprachdateien der Anwendung überschreiben.
|
|
2
|
+
de:
|
|
3
|
+
tillbell:
|
|
4
|
+
paywall:
|
|
5
|
+
submit: "Abonnieren"
|
|
6
|
+
restore: "Käufe wiederherstellen"
|
|
7
|
+
manage: "Abo verwalten"
|
|
8
|
+
fallback: "Abos sind in der neuesten Version der Android-App verfügbar."
|
|
9
|
+
messages:
|
|
10
|
+
verifying: "Wir prüfen Ihren Kauf. Das kann einige Minuten dauern."
|
|
11
|
+
pending: "Ihre Zahlung steht noch aus. Ihr Abo beginnt, sobald sie bestätigt ist."
|
|
12
|
+
unavailable: "Google Play Billing ist auf diesem Gerät nicht verfügbar."
|
|
13
|
+
nothing_to_restore: "Keine Käufe zum Wiederherstellen."
|
|
14
|
+
rejected: "Dieser Kauf gehört zu einem anderen Konto."
|
|
15
|
+
error: "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Textos del paywall en español. Cada texto puede sustituirse en los archivos de idioma de la aplicación.
|
|
2
|
+
es:
|
|
3
|
+
tillbell:
|
|
4
|
+
paywall:
|
|
5
|
+
submit: "Suscribirse"
|
|
6
|
+
restore: "Restaurar compras"
|
|
7
|
+
manage: "Gestionar suscripción"
|
|
8
|
+
fallback: "Las suscripciones están disponibles en la última versión de la aplicación Android."
|
|
9
|
+
messages:
|
|
10
|
+
verifying: "Estamos comprobando su compra. Puede tardar unos minutos."
|
|
11
|
+
pending: "Su pago está pendiente. Su suscripción empieza en cuanto se confirme."
|
|
12
|
+
unavailable: "La facturación de Google Play no está disponible en este dispositivo."
|
|
13
|
+
nothing_to_restore: "No hay compras que restaurar."
|
|
14
|
+
rejected: "Esta compra pertenece a otra cuenta."
|
|
15
|
+
error: "Algo ha salido mal. Inténtelo de nuevo."
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Textes du paywall en français. Chaque texte peut être remplacé dans les fichiers de langue de l'application.
|
|
2
|
+
fr:
|
|
3
|
+
tillbell:
|
|
4
|
+
paywall:
|
|
5
|
+
submit: "S'abonner"
|
|
6
|
+
restore: "Restaurer mes achats"
|
|
7
|
+
manage: "Gérer l'abonnement"
|
|
8
|
+
fallback: "Les abonnements sont disponibles dans la dernière version de l'application Android."
|
|
9
|
+
messages:
|
|
10
|
+
verifying: "Nous vérifions votre achat. Cela peut prendre quelques minutes."
|
|
11
|
+
pending: "Votre paiement est en attente. Votre abonnement démarre dès qu'il est confirmé."
|
|
12
|
+
unavailable: "La facturation Google Play n'est pas disponible sur cet appareil."
|
|
13
|
+
nothing_to_restore: "Aucun achat à restaurer."
|
|
14
|
+
rejected: "Cet achat appartient à un autre compte."
|
|
15
|
+
error: "Une erreur est survenue. Veuillez réessayer."
|
|
@@ -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
|
|
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.presence || Tillbell.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. Defaults to +Tillbell.plans+; without
|
|
27
|
+
# any, +online+ lists what the Play Console has.
|
|
28
|
+
def initialize(online: false, plans: Tillbell.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.to_h.symbolize_keys.slice(:product_id, :base_plan_id) if plan.respond_to?(:to_h)
|
|
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
|
data/lib/tillbell/engine.rb
CHANGED
|
@@ -27,6 +27,15 @@ module Tillbell
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
ActiveSupport.on_load(:pay_subscription) do
|
|
30
|
+
# True while the processor still bills the subscription, or will resume it by itself:
|
|
31
|
+
# active (including a canceled one that has not ended), retried after a failed payment
|
|
32
|
+
# (past_due, unpaid) or paused (a Stripe pause keeps the status "active" and resumes by
|
|
33
|
+
# itself). These are the ones to cancel before closing an account. A Google Play purchase
|
|
34
|
+
# still pending (cash payments) is not: Google cannot cancel it until it completes.
|
|
35
|
+
def billing?
|
|
36
|
+
!ended? && (active? || past_due? || unpaid? || paused?)
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
scope :google_play, -> { joins(:customer).where(pay_customers: { processor: "google_play" }) }
|
|
31
40
|
|
|
32
41
|
def google_play?
|
data/lib/tillbell/google_play.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Tillbell
|
|
|
13
13
|
# Errors worth retrying later: rate limiting, Google server errors and network failures.
|
|
14
14
|
TRANSIENT_ERRORS = [ Google::Apis::RateLimitError, Google::Apis::ServerError, Google::Apis::TransmissionError ].freeze
|
|
15
15
|
|
|
16
|
+
# Everything a call to Google can raise besides a bug: API errors, and the errors of the
|
|
17
|
+
# request for an access token (a revoked key, Google's token server failing).
|
|
18
|
+
API_ERRORS = [ Google::Apis::Error, Signet::AuthorizationError, Signet::RemoteServerError, Signet::UnexpectedStatusError ].freeze
|
|
19
|
+
|
|
16
20
|
# HTTP statuses Google answers for a purchase token it does not know (invented,
|
|
17
21
|
# malformed, or expired for more than 60 days). Other client errors, such as a 403
|
|
18
22
|
# for a service account without access to the Play Console, are configuration
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Tillbell
|
|
2
|
+
# Subscription helpers for the model that declares +pay_customer+ (usually +User+), for an
|
|
3
|
+
# owner who may have subscriptions with several Pay processors (Stripe on the web, Google
|
|
4
|
+
# Play in the app).
|
|
5
|
+
#
|
|
6
|
+
# class User < ApplicationRecord
|
|
7
|
+
# pay_customer
|
|
8
|
+
# include Tillbell::Owner
|
|
9
|
+
# end
|
|
10
|
+
module Owner
|
|
11
|
+
# The Google Play subscription of the owner, or nil. Pay's +payment_processor+ is the owner's
|
|
12
|
+
# default processor, which a Google Play customer never is (see the README).
|
|
13
|
+
def google_play_subscription
|
|
14
|
+
customer = pay_customers.active.find_by(processor: "google_play")
|
|
15
|
+
# Not Pay::Customer#subscription, which only looks at the default subscription name.
|
|
16
|
+
subscriptions = customer&.subscriptions&.order(created_at: :desc)
|
|
17
|
+
subscriptions&.active_or_paused&.first || subscriptions&.first
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# True when the Google Play subscription is on hold: the payment failed after the grace
|
|
21
|
+
# period and Google requires access to be suspended while it retries. The customer fixes it
|
|
22
|
+
# in the Play Store (+manage_url+); subscribing again elsewhere would bill twice if the
|
|
23
|
+
# retry succeeds.
|
|
24
|
+
def google_play_on_hold?
|
|
25
|
+
google_play_subscription&.on_hold? || false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The subscription to show and to decide access from, whatever its processor: the one
|
|
29
|
+
# that renews, then one that is canceled but still running, then one whose payment is
|
|
30
|
+
# being retried or that is paused; the most recent breaks a tie. Nil without any.
|
|
31
|
+
def current_subscription
|
|
32
|
+
pay_subscriptions.includes(:customer).min_by { |subscription| [ subscription_standing(subscription), -subscription.created_at.to_i ] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The subscriptions that still bill the owner, or will resume by themselves (see
|
|
36
|
+
# +Pay::Subscription#billing?+), Google Play first: its cancellation can be undone in the
|
|
37
|
+
# Play Store and Stripe's cannot, so a Google failure must not leave a customer who
|
|
38
|
+
# already lost the other one.
|
|
39
|
+
def billing_subscriptions
|
|
40
|
+
pay_subscriptions.includes(:customer).select(&:billing?).sort_by { |subscription| subscription.google_play? ? 0 : 1 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Cancels what +billing_subscriptions+ lists, to call before closing or deleting the
|
|
44
|
+
# account and before changing anything in the database: an error raised afterwards would
|
|
45
|
+
# leave a closed account that is still billed. Google Play subscriptions only stop
|
|
46
|
+
# renewing, the customer keeps access until the end of the paid period. Raises +Pay::Error+
|
|
47
|
+
# (+Tillbell::Error+ for Google Play) when a processor refuses.
|
|
48
|
+
def cancel_billing_subscriptions!
|
|
49
|
+
billing_subscriptions.each(&:cancel_now!)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
def subscription_standing(subscription)
|
|
54
|
+
if subscription.active? && !subscription.on_grace_period? then 0
|
|
55
|
+
elsif subscription.active? then 1
|
|
56
|
+
elsif subscription.billing? then 2
|
|
57
|
+
else 3
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/tillbell/version.rb
CHANGED
data/lib/tillbell.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "pay"
|
|
|
2
2
|
require "tillbell/version"
|
|
3
3
|
require "tillbell/engine"
|
|
4
4
|
require "tillbell/google_play"
|
|
5
|
+
require "tillbell/check"
|
|
6
|
+
require "tillbell/owner"
|
|
5
7
|
|
|
6
8
|
# Tillbell adds Google Play subscriptions to Rails apps that use the Pay gem.
|
|
7
9
|
#
|
|
@@ -13,8 +15,8 @@ require "tillbell/google_play"
|
|
|
13
15
|
#
|
|
14
16
|
# Tillbell.configure do |config|
|
|
15
17
|
# config.google.package_name = "com.example.app"
|
|
16
|
-
# config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :
|
|
17
|
-
# config.google.pubsub_service_account_email =
|
|
18
|
+
# config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
|
|
19
|
+
# config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
|
|
18
20
|
#
|
|
19
21
|
# config.on(:subscription_created) { |pay_subscription| ... }
|
|
20
22
|
# end
|
|
@@ -23,8 +25,17 @@ module Tillbell
|
|
|
23
25
|
class Error < Pay::Error; end
|
|
24
26
|
|
|
25
27
|
# The events Tillbell emits, in the order a subscription usually goes through them.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
# +subscription_updated+ fires for any change; +subscription_renewed+, +payment_failed+,
|
|
29
|
+
# +payment_recovered+ and +plan_changed+ say which one happened.
|
|
30
|
+
EVENTS = %i[subscription_created subscription_updated subscription_renewed payment_failed payment_recovered plan_changed
|
|
31
|
+
subscription_canceled subscription_expired subscription_revoked purchase_rejected].freeze
|
|
32
|
+
|
|
33
|
+
# A plan the app sells: a Google Play product and its base plan, under a name of your own.
|
|
34
|
+
Plan = Data.define(:name, :product_id, :base_plan_id) do
|
|
35
|
+
def initialize(name:, product_id:, base_plan_id: nil)
|
|
36
|
+
super(name: name.to_sym, product_id: product_id, base_plan_id: base_plan_id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
28
39
|
|
|
29
40
|
# Google Play settings, see the README for where each value comes from.
|
|
30
41
|
#
|
|
@@ -47,6 +58,18 @@ module Tillbell
|
|
|
47
58
|
# +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
|
|
48
59
|
mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
|
|
49
60
|
|
|
61
|
+
# The plans the app sells, from the lowest to the highest: the order decides whether a plan
|
|
62
|
+
# change is an upgrade or a downgrade (see +replacement_mode+). Assign hashes:
|
|
63
|
+
#
|
|
64
|
+
# config.plans = [
|
|
65
|
+
# { name: :solo, product_id: "app_solo", base_plan_id: "monthly" },
|
|
66
|
+
# { name: :family, product_id: "app_family", base_plan_id: "monthly" }
|
|
67
|
+
# ]
|
|
68
|
+
#
|
|
69
|
+
# +tillbell:check+ checks them with Google, and +Pay::GooglePlay::Subscription#plan+ finds
|
|
70
|
+
# the one a subscription is on.
|
|
71
|
+
mattr_reader :plans, default: [].freeze
|
|
72
|
+
|
|
50
73
|
# What Hotwire Native writes in the user agent for the bridge components the app
|
|
51
74
|
# registered, e.g. "bridge-components: [haptic tillbell]". +tillbell+ must be in that
|
|
52
75
|
# list for the paywall to work.
|
|
@@ -66,6 +89,41 @@ module Tillbell
|
|
|
66
89
|
NATIVE_USER_AGENT.match?(user_agent.to_s)
|
|
67
90
|
end
|
|
68
91
|
|
|
92
|
+
# Sets the catalog of +plans+ from hashes with +name+, +product_id+ and optionally
|
|
93
|
+
# +base_plan_id+ (or from +Tillbell::Plan+ objects).
|
|
94
|
+
def plans=(list)
|
|
95
|
+
@@plans = list.map do |plan|
|
|
96
|
+
next plan if plan.is_a?(Plan)
|
|
97
|
+
raise ArgumentError, "a plan is a hash with :name, :product_id and :base_plan_id, got #{plan.inspect}" unless plan.respond_to?(:to_h) && !plan.nil?
|
|
98
|
+
|
|
99
|
+
Plan.new(**plan.to_h.symbolize_keys)
|
|
100
|
+
end.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Finds a plan of +plans+ by +name+ (a symbol or string) or by Google ids. With a
|
|
104
|
+
# +base_plan_id+ that no plan has, falls back to the product's plan when it has only one.
|
|
105
|
+
def plan(name = nil, product_id: nil, base_plan_id: nil)
|
|
106
|
+
return plans.find { |plan| plan.name == name.to_sym } if name
|
|
107
|
+
|
|
108
|
+
matches = plans.select { |plan| plan.product_id == product_id }
|
|
109
|
+
matches.find { |plan| plan.base_plan_id == base_plan_id } || (matches.first if matches.one?)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The Google replacement mode for moving from plan +from+ to plan +to+ (plans, or their
|
|
113
|
+
# names): +CHARGE_PRORATED_PRICE+ for an upgrade, charged now for the difference with the
|
|
114
|
+
# renewal date unchanged, and +DEFERRED+ for a downgrade, which takes effect at the next
|
|
115
|
+
# renewal because the customer already paid the current period and Google does not
|
|
116
|
+
# refund it. Raises +ArgumentError+ for a plan that is not in +plans+ or a move to the
|
|
117
|
+
# same plan.
|
|
118
|
+
def replacement_mode(from:, to:)
|
|
119
|
+
ranks = [ from, to ].map do |plan|
|
|
120
|
+
plans.index { |candidate| candidate == plan || candidate.name == plan.try(:to_sym) } || raise(ArgumentError, "#{plan.inspect} is not in Tillbell.plans")
|
|
121
|
+
end
|
|
122
|
+
raise ArgumentError, "#{from.inspect} and #{to.inspect} are the same plan" if ranks.first == ranks.last
|
|
123
|
+
|
|
124
|
+
ranks.last > ranks.first ? "CHARGE_PRORATED_PRICE" : "DEFERRED"
|
|
125
|
+
end
|
|
126
|
+
|
|
69
127
|
# Yields the Tillbell module so settings can be assigned in a block.
|
|
70
128
|
def configure
|
|
71
129
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pierre Filstroff
|
|
@@ -82,7 +82,10 @@ files:
|
|
|
82
82
|
- app/jobs/tillbell/sync_job.rb
|
|
83
83
|
- app/models/pay/google_play/customer.rb
|
|
84
84
|
- app/models/pay/google_play/subscription.rb
|
|
85
|
+
- config/locales/de.yml
|
|
85
86
|
- config/locales/en.yml
|
|
87
|
+
- config/locales/es.yml
|
|
88
|
+
- config/locales/fr.yml
|
|
86
89
|
- config/routes.rb
|
|
87
90
|
- contract/examples/manage.request.json
|
|
88
91
|
- contract/examples/products.reply.ok.json
|
|
@@ -108,10 +111,14 @@ files:
|
|
|
108
111
|
- contract/restore.request.json
|
|
109
112
|
- lib/generators/tillbell/install/install_generator.rb
|
|
110
113
|
- lib/generators/tillbell/install/templates/initializer.rb
|
|
114
|
+
- lib/generators/tillbell/install/templates/next_steps.txt
|
|
111
115
|
- lib/generators/tillbell/install/templates/tillbell_controller.js
|
|
116
|
+
- lib/tasks/tillbell.rake
|
|
112
117
|
- lib/tillbell.rb
|
|
118
|
+
- lib/tillbell/check.rb
|
|
113
119
|
- lib/tillbell/engine.rb
|
|
114
120
|
- lib/tillbell/google_play.rb
|
|
121
|
+
- lib/tillbell/owner.rb
|
|
115
122
|
- lib/tillbell/version.rb
|
|
116
123
|
homepage: https://github.com/pmakerhq/tillbell-rails
|
|
117
124
|
licenses:
|