tillbell 0.3.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 +26 -0
- data/README.md +45 -4
- 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/tasks/tillbell.rake +1 -1
- data/lib/tillbell/check.rb +4 -4
- 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 +59 -2
- metadata +5 -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,32 @@ 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
|
+
|
|
9
35
|
## [0.3.0] - 2026-10-30
|
|
10
36
|
|
|
11
37
|
### Added
|
data/README.md
CHANGED
|
@@ -178,6 +178,7 @@ The values marked "credentials" below come from Google: the [Google Play setup](
|
|
|
178
178
|
| `google.pubsub_audience` | endpoint URL | Audience of the Pub/Sub OIDC token. Set it when a proxy changes the URL Rails sees. |
|
|
179
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`, ...). |
|
|
180
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. |
|
|
181
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:)`. |
|
|
182
183
|
|
|
183
184
|
## The paywall
|
|
@@ -199,7 +200,7 @@ The values marked "credentials" below come from Google: the [Google Play setup](
|
|
|
199
200
|
<% end %>
|
|
200
201
|
```
|
|
201
202
|
|
|
202
|
-
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.
|
|
203
204
|
|
|
204
205
|
| Builder method | Renders |
|
|
205
206
|
|---|---|
|
|
@@ -297,6 +298,42 @@ A `Pay::GooglePlay::Subscription` stores:
|
|
|
297
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. |
|
|
298
299
|
| `in_grace_period?`, `on_hold?`, `revoked?` | The Google states that Pay statuses merge. |
|
|
299
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
|
+
|
|
300
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.
|
|
301
338
|
|
|
302
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`).
|
|
@@ -306,10 +343,10 @@ Pay only knows its own processors, so Tillbell adds `google_play?` to customers
|
|
|
306
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:
|
|
307
344
|
|
|
308
345
|
```ruby
|
|
309
|
-
user.
|
|
346
|
+
user.cancel_billing_subscriptions! # with Tillbell::Owner: what `billing?` says still bills, Google Play first
|
|
310
347
|
```
|
|
311
348
|
|
|
312
|
-
|
|
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.
|
|
313
350
|
|
|
314
351
|
## Events
|
|
315
352
|
|
|
@@ -317,6 +354,10 @@ Google can fail (API error, revoked service account key), so rescue `Pay::Error`
|
|
|
317
354
|
Tillbell.configure do |config|
|
|
318
355
|
config.on(:subscription_created) { |subscription| WelcomeMailer.with(user: subscription.owner).premium.deliver_later }
|
|
319
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
|
|
320
361
|
config.on(:subscription_canceled) { |subscription| } # will not renew; access until ends_at
|
|
321
362
|
config.on(:subscription_expired) { |subscription| }
|
|
322
363
|
config.on(:subscription_revoked) { |subscription| } # refunded or charged back
|
|
@@ -324,7 +365,7 @@ Tillbell.configure do |config|
|
|
|
324
365
|
end
|
|
325
366
|
```
|
|
326
367
|
|
|
327
|
-
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:
|
|
328
369
|
|
|
329
370
|
```ruby
|
|
330
371
|
Tillbell.on(:subscription_updated) do |subscription|
|
|
@@ -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."
|
data/lib/tasks/tillbell.rake
CHANGED
|
@@ -4,7 +4,7 @@ namespace :tillbell do
|
|
|
4
4
|
icons = { ok: "OK ", warn: "WARN", fail: "FAIL" }
|
|
5
5
|
online = !%w[0 false no].include?(ENV["ONLINE"].to_s.strip.downcase) && ENV["ONLINE"].present?
|
|
6
6
|
plans = ENV["PLANS"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
7
|
-
check = Tillbell::Check.new(online: online, plans: plans)
|
|
7
|
+
check = Tillbell::Check.new(online: online, plans: plans.presence || Tillbell.plans)
|
|
8
8
|
|
|
9
9
|
check.call.each do |result|
|
|
10
10
|
puts "#{icons.fetch(result.status)} #{result.label}#{": #{result.detail}" if result.detail}"
|
data/lib/tillbell/check.rb
CHANGED
|
@@ -23,9 +23,9 @@ module Tillbell
|
|
|
23
23
|
# subscriptions of the Play Console.
|
|
24
24
|
# [plans] Plans the app sells, as +"product_id:base_plan_id"+ strings or
|
|
25
25
|
# +{ product_id:, base_plan_id: }+ hashes. With +online+, each must exist in the
|
|
26
|
-
# Play Console with an active base plan.
|
|
27
|
-
# Play Console has.
|
|
28
|
-
def initialize(online: false, plans:
|
|
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
29
|
@online = online
|
|
30
30
|
@plans = Array(plans).map { |plan| normalize(plan) }
|
|
31
31
|
@results = []
|
|
@@ -59,7 +59,7 @@ module Tillbell
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def normalize(plan)
|
|
62
|
-
return plan.symbolize_keys.slice(:product_id, :base_plan_id) if plan.respond_to?(:
|
|
62
|
+
return plan.to_h.symbolize_keys.slice(:product_id, :base_plan_id) if plan.respond_to?(:to_h)
|
|
63
63
|
|
|
64
64
|
product_id, base_plan_id = plan.to_s.split(":", 2)
|
|
65
65
|
{ product_id: product_id, base_plan_id: base_plan_id.presence }
|
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
|
@@ -3,6 +3,7 @@ require "tillbell/version"
|
|
|
3
3
|
require "tillbell/engine"
|
|
4
4
|
require "tillbell/google_play"
|
|
5
5
|
require "tillbell/check"
|
|
6
|
+
require "tillbell/owner"
|
|
6
7
|
|
|
7
8
|
# Tillbell adds Google Play subscriptions to Rails apps that use the Pay gem.
|
|
8
9
|
#
|
|
@@ -24,8 +25,17 @@ module Tillbell
|
|
|
24
25
|
class Error < Pay::Error; end
|
|
25
26
|
|
|
26
27
|
# The events Tillbell emits, in the order a subscription usually goes through them.
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
29
39
|
|
|
30
40
|
# Google Play settings, see the README for where each value comes from.
|
|
31
41
|
#
|
|
@@ -48,6 +58,18 @@ module Tillbell
|
|
|
48
58
|
# +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
|
|
49
59
|
mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
|
|
50
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
|
+
|
|
51
73
|
# What Hotwire Native writes in the user agent for the bridge components the app
|
|
52
74
|
# registered, e.g. "bridge-components: [haptic tillbell]". +tillbell+ must be in that
|
|
53
75
|
# list for the paywall to work.
|
|
@@ -67,6 +89,41 @@ module Tillbell
|
|
|
67
89
|
NATIVE_USER_AGENT.match?(user_agent.to_s)
|
|
68
90
|
end
|
|
69
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
|
+
|
|
70
127
|
# Yields the Tillbell module so settings can be assigned in a block.
|
|
71
128
|
def configure
|
|
72
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
|
|
@@ -115,6 +118,7 @@ files:
|
|
|
115
118
|
- lib/tillbell/check.rb
|
|
116
119
|
- lib/tillbell/engine.rb
|
|
117
120
|
- lib/tillbell/google_play.rb
|
|
121
|
+
- lib/tillbell/owner.rb
|
|
118
122
|
- lib/tillbell/version.rb
|
|
119
123
|
homepage: https://github.com/pmakerhq/tillbell-rails
|
|
120
124
|
licenses:
|