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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12dfa4c6d7a3864417897be9a747fefbe25e4b1507665cea18427357bde1d6e6
4
- data.tar.gz: 30ad262de35244e9262452acce2734ee92245274bd66f6f74205f798d5e01695
3
+ metadata.gz: 3e2e56c0874935103736d6f90d868ec525033d4abe32437269b39ea2772d224b
4
+ data.tar.gz: dde35b5c71bee8fee12aef4ba5fd99c5870d034da7150a8fbcaea55d4dfc5a6c
5
5
  SHA512:
6
- metadata.gz: 5d140d39754930be99c4c93de83e51b2947e609e3bff3b0629bad2a95421e806c83e14ce08df900bb78049e869f23802e6d0692506c012feaa58d36649a56de2
7
- data.tar.gz: e8118449e7d79da14d3c6b7fc40b0e23aaf20c342343e8f1e77928b15ad4d3ed2a4a6bd14b3915ebb5aa9af1616e5fdc38dcbb1f2fc70f2c423eb7d399599257
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.pay_subscriptions.where(status: %w[active past_due paused]).reject(&:ended?).each(&:cancel_now!)
346
+ user.cancel_billing_subscriptions! # with Tillbell::Owner: what `billing?` says still bills, Google Play first
310
347
  ```
311
348
 
312
- Google can fail (API error, revoked service account key), so rescue `Pay::Error` (which includes `Tillbell::Error`, raised when the package name or the service account is not configured), `Google::Apis::Error` and `Signet::AuthorizationError` next to your other processors' errors, and tell the customer to try again.
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) and `subscription_expired` for the old one (`replaced?` is true). Renewals fire `subscription_updated` too, so filter on what you care about, for example in a Slack or analytics subscriber:
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, event = save_from_google(pay_customer, purchase_token, attributes_from(object, revoked: revoked))
66
- Tillbell.instrument(event, subscription) if event
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
- # event its changes call for. Concurrent syncs of the same token (purchase
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
- event = subscription.event_for_changes
121
+ events = subscription.events_for_changes
114
122
  subscription.save!
115
- [ subscription, event ]
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
- where(processor_id: linked_purchase_token).where.not(status: "canceled").find_each do |subscription|
132
- subscription.update!(status: "canceled", ends_at: Time.current, replaced_by: replaced_by)
133
- Tillbell.instrument(:subscription_expired, subscription)
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) is raised: the subscription may
245
- # still be billing.
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 Google::Apis::ClientError => error
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."
@@ -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}"
@@ -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. Without plans, +online+ lists what the
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?(:symbolize_keys)
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 }
@@ -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?
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Tillbell
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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
- EVENTS = %i[subscription_created subscription_updated subscription_canceled subscription_expired subscription_revoked
28
- purchase_rejected].freeze
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.3.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: