tillbell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/MIT-LICENSE +20 -0
- data/README.md +341 -0
- data/app/controllers/tillbell/notifications_controller.rb +71 -0
- data/app/controllers/tillbell/purchases_controller.rb +44 -0
- data/app/helpers/tillbell/paywall_builder.rb +81 -0
- data/app/helpers/tillbell/paywall_helper.rb +40 -0
- data/app/jobs/tillbell/sync_job.rb +21 -0
- data/app/models/pay/google_play/customer.rb +52 -0
- data/app/models/pay/google_play/subscription.rb +197 -0
- data/config/locales/en.yml +16 -0
- data/config/routes.rb +7 -0
- data/contract/examples/manage.request.json +3 -0
- data/contract/examples/products.reply.ok.json +15 -0
- data/contract/examples/products.reply.unavailable.json +4 -0
- data/contract/examples/products.request.json +15 -0
- data/contract/examples/purchase.reply.already_owned.json +3 -0
- data/contract/examples/purchase.reply.canceled.json +3 -0
- data/contract/examples/purchase.reply.error.json +4 -0
- data/contract/examples/purchase.reply.pending.json +5 -0
- data/contract/examples/purchase.reply.purchased.json +5 -0
- data/contract/examples/purchase.reply.unavailable.json +3 -0
- data/contract/examples/purchase.request.json +8 -0
- data/contract/examples/purchase.request.plan_change.json +7 -0
- data/contract/examples/restore.reply.empty.json +4 -0
- data/contract/examples/restore.reply.ok.json +9 -0
- data/contract/examples/restore.request.json +5 -0
- data/contract/manage.request.json +28 -0
- data/contract/products.reply.json +68 -0
- data/contract/products.request.json +47 -0
- data/contract/purchase.reply.json +54 -0
- data/contract/purchase.request.json +65 -0
- data/contract/restore.reply.json +58 -0
- data/contract/restore.request.json +19 -0
- data/lib/generators/tillbell/install/install_generator.rb +34 -0
- data/lib/generators/tillbell/install/templates/initializer.rb +33 -0
- data/lib/generators/tillbell/install/templates/tillbell_controller.js +147 -0
- data/lib/tillbell/engine.rb +42 -0
- data/lib/tillbell/google_play.rb +110 -0
- data/lib/tillbell/version.rb +3 -0
- data/lib/tillbell.rb +91 -0
- metadata +142 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/restore.reply.json",
|
|
4
|
+
"title": "restore (native to page)",
|
|
5
|
+
"description": "Subscription purchases of the device's Google account. The page posts each token to the server.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"result"
|
|
9
|
+
],
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"result": {
|
|
13
|
+
"enum": [
|
|
14
|
+
"ok",
|
|
15
|
+
"unavailable",
|
|
16
|
+
"error"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"purchases": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"required": [
|
|
24
|
+
"productId",
|
|
25
|
+
"purchaseToken"
|
|
26
|
+
],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"productId": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"minLength": 1,
|
|
32
|
+
"description": "Google Play product id (subscription id)."
|
|
33
|
+
},
|
|
34
|
+
"purchaseToken": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"minLength": 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"message": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Developer-facing detail, never shown to the customer."
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"if": {
|
|
47
|
+
"properties": {
|
|
48
|
+
"result": {
|
|
49
|
+
"const": "ok"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"then": {
|
|
54
|
+
"required": [
|
|
55
|
+
"purchases"
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/restore.request.json",
|
|
4
|
+
"title": "restore (page to native)",
|
|
5
|
+
"description": "Asks the native app for the subscriptions Google Play reports for the device's Google account (queryPurchasesAsync).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"metadata": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"description": "Added by @hotwired/hotwire-native-bridge to every message sent by the page.",
|
|
12
|
+
"properties": {
|
|
13
|
+
"url": {
|
|
14
|
+
"type": "string"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Tillbell
|
|
2
|
+
module Generators
|
|
3
|
+
# Sets Tillbell up in a Rails app: +rails generate tillbell:install+.
|
|
4
|
+
#
|
|
5
|
+
# Tillbell stores everything in Pay's tables, so there is no migration to copy.
|
|
6
|
+
# Run Pay's own installation first if the app does not use Pay yet.
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates the Tillbell initializer, mounts the engine and copies the paywall Stimulus controller."
|
|
11
|
+
|
|
12
|
+
def create_initializer
|
|
13
|
+
copy_file "initializer.rb", "config/initializers/tillbell.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mount_engine
|
|
17
|
+
route 'mount Tillbell::Engine, at: "/tillbell"'
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def copy_stimulus_controller
|
|
21
|
+
copy_file "tillbell_controller.js", "app/javascript/controllers/tillbell_controller.js"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def install_bridge_package
|
|
25
|
+
if File.exist?(File.join(destination_root, "config/importmap.rb"))
|
|
26
|
+
run "bin/importmap pin @hotwired/hotwire-native-bridge"
|
|
27
|
+
else
|
|
28
|
+
say "Add the Hotwire Native bridge to your JavaScript dependencies: " \
|
|
29
|
+
"npm install @hotwired/hotwire-native-bridge", :yellow
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Tillbell: Google Play subscriptions stored as Pay subscriptions.
|
|
2
|
+
# See https://github.com/pmakerhq/tillbell-rails#configuration
|
|
3
|
+
Tillbell.configure do |config|
|
|
4
|
+
# Application id of the Android app.
|
|
5
|
+
config.google.package_name = "com.example.app"
|
|
6
|
+
|
|
7
|
+
# JSON key of a Google Cloud service account invited in the Play Console with the
|
|
8
|
+
# "View financial data" and "Manage orders and subscriptions" permissions.
|
|
9
|
+
config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google_service_account)
|
|
10
|
+
|
|
11
|
+
# Service account the Pub/Sub push subscription authenticates with.
|
|
12
|
+
config.google.pubsub_service_account_email = Rails.application.credentials.dig(:tillbell, :pubsub_service_account_email)
|
|
13
|
+
|
|
14
|
+
# Audience of the Pub/Sub OIDC token, if it is not the notification endpoint URL.
|
|
15
|
+
# config.google.pubsub_audience = "https://example.com/tillbell/notifications/google_play"
|
|
16
|
+
|
|
17
|
+
# Who is buying. Evaluated in the controller and the view of the paywall.
|
|
18
|
+
# config.current_owner = -> { current_user }
|
|
19
|
+
|
|
20
|
+
# Controller the purchase endpoint inherits from (authentication, CSRF, ...).
|
|
21
|
+
# config.parent_controller = "ApplicationController"
|
|
22
|
+
|
|
23
|
+
# Pay subscription name of a Google Play product, as used by `subscribed?(name:)`.
|
|
24
|
+
# config.subscription_name = ->(product_id:, base_plan_id:) { Pay.default_product_name }
|
|
25
|
+
|
|
26
|
+
# React to subscription changes. Each block receives the Pay::GooglePlay::Subscription.
|
|
27
|
+
# config.on(:subscription_created) { |subscription| }
|
|
28
|
+
# config.on(:subscription_updated) { |subscription| }
|
|
29
|
+
# config.on(:subscription_canceled) { |subscription| }
|
|
30
|
+
# config.on(:subscription_expired) { |subscription| }
|
|
31
|
+
# config.on(:subscription_revoked) { |subscription| }
|
|
32
|
+
# config.on(:purchase_rejected) { |details| } # a token posted by another account
|
|
33
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Tillbell paywall controller, copied into your app by `rails generate tillbell:install`.
|
|
2
|
+
// It is yours now: change it as you like. The messages it exchanges with the native app
|
|
3
|
+
// are documented in docs/bridge-contract.md of the tillbell gem.
|
|
4
|
+
//
|
|
5
|
+
// The controller only connects inside a Hotwire Native app that registers the
|
|
6
|
+
// "tillbell" bridge component. Everywhere else the paywall keeps its buttons disabled
|
|
7
|
+
// and shows its fallback, so a purchase can never start without the native app.
|
|
8
|
+
//
|
|
9
|
+
// Events dispatched on the paywall element, for your own code to listen to:
|
|
10
|
+
// tillbell:verified the purchase grants access. Cancel the event to stay on the page
|
|
11
|
+
// instead of going to the success URL.
|
|
12
|
+
// tillbell:status a status message is shown (detail: { key, message }).
|
|
13
|
+
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
|
|
14
|
+
|
|
15
|
+
export default class extends BridgeComponent {
|
|
16
|
+
static component = "tillbell"
|
|
17
|
+
static targets = ["plan", "price", "action", "status", "fallback"]
|
|
18
|
+
static values = { accountId: String, purchaseUrl: String, successUrl: String, messages: Object }
|
|
19
|
+
|
|
20
|
+
connect() {
|
|
21
|
+
super.connect()
|
|
22
|
+
this.fallbackTargets.forEach((element) => element.hidden = true)
|
|
23
|
+
this.loadPrices()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Asks Google Play for the localized price of every plan, then enables the paywall.
|
|
27
|
+
loadPrices() {
|
|
28
|
+
const products = this.planTargets.map((plan) => this.#productOf(plan))
|
|
29
|
+
|
|
30
|
+
this.send("products", { products }, ({ data }) => {
|
|
31
|
+
if (data.result !== "ok") return this.#showStatus(data.result === "unavailable" ? "unavailable" : "error")
|
|
32
|
+
|
|
33
|
+
this.planTargets.forEach((plan) => {
|
|
34
|
+
const { productId, basePlanId } = this.#productOf(plan)
|
|
35
|
+
const product = data.products.find((p) => p.productId === productId && (p.basePlanId ?? null) === basePlanId)
|
|
36
|
+
const price = plan.closest("label")?.querySelector("[data-tillbell-target~='price']")
|
|
37
|
+
|
|
38
|
+
if (price && product) price.textContent = product.formattedPrice
|
|
39
|
+
plan.disabled = !product
|
|
40
|
+
})
|
|
41
|
+
this.#setBusy(false)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Opens the Google Play purchase sheet for the checked plan.
|
|
46
|
+
purchase() {
|
|
47
|
+
const plan = this.planTargets.find((plan) => plan.checked && !plan.disabled)
|
|
48
|
+
if (!plan) return
|
|
49
|
+
|
|
50
|
+
const { oldPurchaseToken, replacementMode } = plan.dataset
|
|
51
|
+
const message = { ...this.#productOf(plan), obfuscatedAccountId: this.accountIdValue }
|
|
52
|
+
if (oldPurchaseToken) Object.assign(message, { oldPurchaseToken, replacementMode })
|
|
53
|
+
|
|
54
|
+
this.#setBusy(true)
|
|
55
|
+
this.send("purchase", message, ({ data }) => this.#handlePurchase(data))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Records again the purchases Google Play knows for this device's account.
|
|
59
|
+
restorePurchases() {
|
|
60
|
+
this.#setBusy(true)
|
|
61
|
+
this.send("restore", {}, async ({ data }) => {
|
|
62
|
+
if (data.result !== "ok") return this.#fail(data.result === "unavailable" ? "unavailable" : "error")
|
|
63
|
+
if (data.purchases.length === 0) return this.#fail("nothing_to_restore")
|
|
64
|
+
|
|
65
|
+
const results = []
|
|
66
|
+
for (const { purchaseToken } of data.purchases) results.push(await this.#record(purchaseToken))
|
|
67
|
+
this.#finish(results.find((result) => result.active) ?? results[0])
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Opens the Play Store page where the customer manages the subscription.
|
|
72
|
+
manage() {
|
|
73
|
+
const plan = this.planTargets.find((plan) => plan.checked) ?? this.planTargets[0]
|
|
74
|
+
this.send("manage", { productId: plan ? plan.dataset.productId : null })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async #handlePurchase({ result, purchaseToken }) {
|
|
78
|
+
switch (result) {
|
|
79
|
+
case "purchased":
|
|
80
|
+
case "pending":
|
|
81
|
+
return this.#finish(await this.#record(purchaseToken))
|
|
82
|
+
case "already_owned":
|
|
83
|
+
return this.restorePurchases()
|
|
84
|
+
case "canceled":
|
|
85
|
+
return this.#setBusy(false)
|
|
86
|
+
case "unavailable":
|
|
87
|
+
return this.#fail("unavailable")
|
|
88
|
+
default:
|
|
89
|
+
return this.#fail("error")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Sends the purchase token to Rails, which verifies it with Google.
|
|
94
|
+
async #record(purchaseToken) {
|
|
95
|
+
this.#showStatus("verifying")
|
|
96
|
+
try {
|
|
97
|
+
const response = await fetch(this.purchaseUrlValue, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
"Accept": "application/json",
|
|
102
|
+
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")?.content
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ purchase_token: purchaseToken })
|
|
105
|
+
})
|
|
106
|
+
return await response.json()
|
|
107
|
+
} catch {
|
|
108
|
+
return { status: "error" }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#finish(body) {
|
|
113
|
+
if (body.status === "verified" && body.active) {
|
|
114
|
+
const event = this.dispatch("verified", { detail: body, cancelable: true })
|
|
115
|
+
event.defaultPrevented ? this.#setBusy(false) : this.#visit(this.successUrlValue)
|
|
116
|
+
} else if (body.status === "verified") {
|
|
117
|
+
this.#fail("pending")
|
|
118
|
+
} else if (body.status === "verifying" || body.status === "rejected") {
|
|
119
|
+
this.#fail(body.status)
|
|
120
|
+
} else {
|
|
121
|
+
this.#fail("error")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#fail(key) {
|
|
126
|
+
this.#showStatus(key)
|
|
127
|
+
this.#setBusy(false)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#showStatus(key) {
|
|
131
|
+
const message = this.messagesValue[key] ?? key
|
|
132
|
+
this.statusTargets.forEach((element) => element.textContent = message)
|
|
133
|
+
this.dispatch("status", { detail: { key, message } })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#setBusy(busy) {
|
|
137
|
+
this.actionTargets.forEach((element) => element.disabled = busy)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#productOf(plan) {
|
|
141
|
+
return { productId: plan.dataset.productId, basePlanId: plan.dataset.basePlanId ?? null }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#visit(url) {
|
|
145
|
+
window.Turbo ? window.Turbo.visit(url) : window.location.assign(url)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Tillbell
|
|
2
|
+
# Mountable engine providing the purchase and notification endpoints, the paywall
|
|
3
|
+
# helper and the Pay processor models.
|
|
4
|
+
#
|
|
5
|
+
# Mount it in the host app's routes:
|
|
6
|
+
#
|
|
7
|
+
# mount Tillbell::Engine, at: "/tillbell"
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace Tillbell
|
|
10
|
+
|
|
11
|
+
# Real-time developer notifications are verified, stored and queued by Pay (see
|
|
12
|
+
# Tillbell::NotificationsController). Once Pay's job processes one, Tillbell re-reads
|
|
13
|
+
# the subscription from Google.
|
|
14
|
+
initializer "tillbell.notifications" do
|
|
15
|
+
Pay::Webhooks.delegator.subscribe "google_play", ->(event) { Tillbell::GooglePlay.handle_notification(event) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Pay defines +stripe?+, +paddle_billing?+ and the matching scopes for its own
|
|
19
|
+
# processors only; this adds the same pair for Google Play.
|
|
20
|
+
initializer "tillbell.processor_helpers" do
|
|
21
|
+
ActiveSupport.on_load(:pay_customer) do
|
|
22
|
+
scope :google_play, -> { where(processor: "google_play") }
|
|
23
|
+
|
|
24
|
+
def google_play?
|
|
25
|
+
processor == "google_play"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
ActiveSupport.on_load(:pay_subscription) do
|
|
30
|
+
scope :google_play, -> { joins(:customer).where(pay_customers: { processor: "google_play" }) }
|
|
31
|
+
|
|
32
|
+
def google_play?
|
|
33
|
+
customer.processor == "google_play"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
initializer "tillbell.helpers" do
|
|
39
|
+
ActiveSupport.on_load(:action_view) { include Tillbell::PaywallHelper }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "google/apis/androidpublisher_v3"
|
|
2
|
+
require "googleauth"
|
|
3
|
+
|
|
4
|
+
module Tillbell
|
|
5
|
+
# Thin client over the Google Play Developer API (Android Publisher v3).
|
|
6
|
+
#
|
|
7
|
+
# Every method returns plain hashes with snake_case symbol keys, so the rest of the
|
|
8
|
+
# gem (and its specs) never deal with the Google client classes.
|
|
9
|
+
module GooglePlay
|
|
10
|
+
SCOPE = "https://www.googleapis.com/auth/androidpublisher".freeze
|
|
11
|
+
|
|
12
|
+
# Errors worth retrying later: rate limiting, Google server errors and network failures.
|
|
13
|
+
TRANSIENT_ERRORS = [ Google::Apis::RateLimitError, Google::Apis::ServerError, Google::Apis::TransmissionError ].freeze
|
|
14
|
+
|
|
15
|
+
# HTTP statuses Google answers for a purchase token it does not know (invented,
|
|
16
|
+
# malformed, or expired for more than 60 days). Other client errors, such as a 403
|
|
17
|
+
# for a service account without access to the Play Console, are configuration
|
|
18
|
+
# problems and must not be mistaken for an invalid purchase.
|
|
19
|
+
INVALID_TOKEN_STATUSES = [ 400, 404, 410 ].freeze
|
|
20
|
+
|
|
21
|
+
# +notificationType+ of a subscription notification sent when a purchase is refunded.
|
|
22
|
+
# See https://developer.android.com/google/play/billing/rtdn-reference
|
|
23
|
+
SUBSCRIPTION_REVOKED = 12
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Handles a real-time developer notification (the decoded Pub/Sub message).
|
|
27
|
+
#
|
|
28
|
+
# A notification is a trigger, never a source of truth: it only says which purchase
|
|
29
|
+
# changed, and the job reads the actual state from Google. This keeps duplicated or
|
|
30
|
+
# out-of-order notifications harmless.
|
|
31
|
+
def handle_notification(notification)
|
|
32
|
+
if (subscription = notification["subscriptionNotification"])
|
|
33
|
+
revoked = subscription["notificationType"] == SUBSCRIPTION_REVOKED
|
|
34
|
+
Tillbell::SyncJob.perform_later(subscription["purchaseToken"], revoked: revoked)
|
|
35
|
+
elsif (voided = notification["voidedPurchaseNotification"])
|
|
36
|
+
Tillbell::SyncJob.perform_later(voided["purchaseToken"], revoked: true)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Fetches the current state of a subscription purchase (+purchases.subscriptionsv2.get+).
|
|
41
|
+
def subscription(purchase_token)
|
|
42
|
+
service.get_purchase_subscriptionsv2(package_name, purchase_token).to_h
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Acknowledges a subscription purchase. Google refunds purchases that are not
|
|
46
|
+
# acknowledged within three days.
|
|
47
|
+
def acknowledge(product_id, purchase_token)
|
|
48
|
+
service.acknowledge_purchase_subscription(package_name, product_id, purchase_token)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Stops the renewal of a subscription. The customer keeps access until the end of
|
|
52
|
+
# the current period.
|
|
53
|
+
def cancel(product_id, purchase_token)
|
|
54
|
+
service.cancel_purchase_subscription(package_name, product_id, purchase_token)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Verifies the OIDC token Pub/Sub sends with a push request and returns its claims.
|
|
58
|
+
# Raises +Tillbell::Error+ unless Google signed it for +audience+ on behalf of the
|
|
59
|
+
# configured service account.
|
|
60
|
+
def verify_pubsub_token!(token, audience:)
|
|
61
|
+
claims = Google::Auth::IDTokens.verify_oidc(token.to_s, aud: audience)
|
|
62
|
+
expected = Tillbell.google.pubsub_service_account_email
|
|
63
|
+
unless expected.present? && claims["email"] == expected && claims["email_verified"]
|
|
64
|
+
raise Error, "Pub/Sub token was not issued for the configured service account"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
claims
|
|
68
|
+
rescue Google::Auth::IDTokens::VerificationError => error
|
|
69
|
+
raise Error, error.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# True when +error+ means that the purchase token itself is invalid.
|
|
73
|
+
def invalid_token?(error)
|
|
74
|
+
error.is_a?(Google::Apis::ClientError) && INVALID_TOKEN_STATUSES.include?(error.status_code)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The +obfuscatedAccountId+ the purchase was made with, i.e. the +processor_id+ of
|
|
78
|
+
# the Pay::GooglePlay::Customer it belongs to.
|
|
79
|
+
def account_id(subscription_purchase)
|
|
80
|
+
subscription_purchase.dig(:external_account_identifiers, :obfuscated_external_account_id)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def package_name
|
|
84
|
+
Tillbell.google.package_name.presence || raise(Error, "Tillbell.google.package_name is not set")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Memoized API client. Clear it with +reset!+ after changing credentials.
|
|
88
|
+
def service
|
|
89
|
+
@service ||= Google::Apis::AndroidpublisherV3::AndroidPublisherService.new.tap do |service|
|
|
90
|
+
service.authorization = Google::Auth::ServiceAccountCredentials.make_creds(
|
|
91
|
+
json_key_io: StringIO.new(service_account_json), scope: SCOPE
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reset!
|
|
97
|
+
@service = nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
# Accepts the key as a JSON string or as a hash (e.g. from Rails credentials).
|
|
102
|
+
def service_account_json
|
|
103
|
+
json = Tillbell.google.service_account_json
|
|
104
|
+
raise Error, "Tillbell.google.service_account_json is not set" if json.blank?
|
|
105
|
+
|
|
106
|
+
json.is_a?(String) ? json : json.to_json
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/tillbell.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "pay"
|
|
2
|
+
require "tillbell/version"
|
|
3
|
+
require "tillbell/engine"
|
|
4
|
+
require "tillbell/google_play"
|
|
5
|
+
|
|
6
|
+
# Tillbell adds Google Play subscriptions to Rails apps that use the Pay gem.
|
|
7
|
+
#
|
|
8
|
+
# Store subscriptions are regular Pay records (a +Pay::GooglePlay::Customer+ and its
|
|
9
|
+
# +Pay::GooglePlay::Subscription+ rows), so they live next to Stripe or any other Pay
|
|
10
|
+
# processor and every Pay helper (+pay_subscriptions.active+, +subscribed?+, ...) sees them.
|
|
11
|
+
#
|
|
12
|
+
# Configure it in an initializer:
|
|
13
|
+
#
|
|
14
|
+
# Tillbell.configure do |config|
|
|
15
|
+
# config.google.package_name = "com.example.app"
|
|
16
|
+
# config.google.service_account_json = Rails.application.credentials.dig(:tillbell, :google)
|
|
17
|
+
# config.google.pubsub_service_account_email = "pubsub-push@example.iam.gserviceaccount.com"
|
|
18
|
+
#
|
|
19
|
+
# config.on(:subscription_created) { |pay_subscription| ... }
|
|
20
|
+
# end
|
|
21
|
+
module Tillbell
|
|
22
|
+
# Base class for every error raised by Tillbell.
|
|
23
|
+
class Error < Pay::Error; end
|
|
24
|
+
|
|
25
|
+
# The events Tillbell emits, in the order a subscription usually goes through them.
|
|
26
|
+
EVENTS = %i[subscription_created subscription_updated subscription_canceled subscription_expired subscription_revoked
|
|
27
|
+
purchase_rejected].freeze
|
|
28
|
+
|
|
29
|
+
# Google Play settings, see the README for where each value comes from.
|
|
30
|
+
#
|
|
31
|
+
# [package_name] Application id of the Android app, e.g. "com.example.app".
|
|
32
|
+
# [service_account_json] JSON key of a service account with access to the Play Console.
|
|
33
|
+
# [pubsub_service_account_email] Service account Pub/Sub signs its push requests with.
|
|
34
|
+
# [pubsub_audience] Audience of those requests; defaults to the notification endpoint URL.
|
|
35
|
+
mattr_accessor :google, default: ActiveSupport::OrderedOptions.new
|
|
36
|
+
|
|
37
|
+
# Returns the Pay owner (usually a User) the current request acts for, or nil.
|
|
38
|
+
# Evaluated with +instance_exec+ in the controller and in the view, so any helper
|
|
39
|
+
# available there (+current_user+, +Current.user+, ...) can be used.
|
|
40
|
+
mattr_accessor :current_owner, default: -> { current_user }
|
|
41
|
+
|
|
42
|
+
# Controller the purchase endpoint inherits from, so it gets the host app's
|
|
43
|
+
# authentication, CSRF protection and error handling.
|
|
44
|
+
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
45
|
+
|
|
46
|
+
# Returns the Pay subscription +name+ for a Google Play product. Pay helpers such as
|
|
47
|
+
# +subscribed?(name:)+ look subscriptions up by name, "default" unless you change it.
|
|
48
|
+
mattr_accessor :subscription_name, default: ->(product_id:, base_plan_id:) { Pay.default_product_name }
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Yields the Tillbell module so settings can be assigned in a block.
|
|
52
|
+
def configure
|
|
53
|
+
yield self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Subscribes to a Tillbell event. The block receives the +Pay::GooglePlay::Subscription+
|
|
57
|
+
# (or, for +:purchase_rejected+, a hash describing the refused purchase).
|
|
58
|
+
#
|
|
59
|
+
# Events go through Pay's webhook delegator, so +Pay::Webhooks.delegator.subscribe
|
|
60
|
+
# "tillbell.subscription_created"+ works too.
|
|
61
|
+
def on(event, callable = nil, &block)
|
|
62
|
+
raise ArgumentError, "unknown Tillbell event #{event.inspect}" unless EVENTS.include?(event.to_sym)
|
|
63
|
+
|
|
64
|
+
Pay::Webhooks.delegator.subscribe("tillbell.#{event}", callable || block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Emits a Tillbell event. Errors raised by subscribers are reported to +Rails.error+
|
|
68
|
+
# instead of propagating: by the time an event fires, the subscription is already
|
|
69
|
+
# saved, and a failing callback must not turn a recorded purchase into an error.
|
|
70
|
+
def instrument(event, payload)
|
|
71
|
+
Pay::Webhooks.instrument(type: "tillbell.#{event}", event: payload)
|
|
72
|
+
rescue StandardError => error
|
|
73
|
+
Rails.error.report(error, handled: true, context: { tillbell_event: event })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Paywall text +key+ (see config/locales/en.yml) in the current locale. A locale without
|
|
77
|
+
# the text, or with only part of the status messages, falls back to English instead of
|
|
78
|
+
# rendering a "translation missing" marker the Stimulus controller cannot parse.
|
|
79
|
+
def translate(key)
|
|
80
|
+
english = I18n.t("tillbell.paywall.#{key}", locale: :en)
|
|
81
|
+
localized = I18n.t("tillbell.paywall.#{key}", default: english)
|
|
82
|
+
english.is_a?(Hash) ? english.merge(localized) : localized
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Shortens a purchase token for logs: tokens grant access to a subscription and
|
|
86
|
+
# must never be written in full.
|
|
87
|
+
def redact(token)
|
|
88
|
+
token.to_s.first(8) + "..."
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|