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,52 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module GooglePlay
|
|
3
|
+
# The Pay customer behind every Google Play purchase of an owner.
|
|
4
|
+
#
|
|
5
|
+
# Its +processor_id+ is a random UUID sent to Google as +obfuscatedAccountId+ when a
|
|
6
|
+
# purchase starts. Google returns it with every purchase, which is how Tillbell knows
|
|
7
|
+
# which owner a purchase token belongs to. It never contains an e-mail or a database
|
|
8
|
+
# id, and the same value can later be reused as an App Store +appAccountToken+.
|
|
9
|
+
#
|
|
10
|
+
# The customer is added next to the owner's other Pay customers (Stripe, ...) without
|
|
11
|
+
# becoming their default payment processor.
|
|
12
|
+
class Customer < Pay::Customer
|
|
13
|
+
has_many :subscriptions, dependent: :destroy, class_name: "Pay::GooglePlay::Subscription"
|
|
14
|
+
|
|
15
|
+
before_validation :generate_processor_id, on: :create
|
|
16
|
+
|
|
17
|
+
# Returns the Google Play customer of +owner+, creating it on first use.
|
|
18
|
+
# +owner+ is any model declaring +pay_customer+.
|
|
19
|
+
def self.for(owner)
|
|
20
|
+
owner.add_payment_processor(:google_play)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Google Play customers exist only in this database.
|
|
24
|
+
def api_record
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update_api_record(**)
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Store purchases always start in the native app, never on the server.
|
|
33
|
+
def subscribe(**)
|
|
34
|
+
raise Tillbell::Error, "Google Play subscriptions are purchased in the Android app, through the Tillbell paywall"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def charge(*)
|
|
38
|
+
raise Tillbell::Error, "Google Play does not support server-side charges"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def add_payment_method(*, **)
|
|
42
|
+
raise Tillbell::Error, "Google Play manages payment methods in the Play Store"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
def generate_processor_id
|
|
47
|
+
self.processor ||= "google_play"
|
|
48
|
+
self.processor_id ||= SecureRandom.uuid
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
module Pay
|
|
2
|
+
module GooglePlay
|
|
3
|
+
# A Google Play subscription, stored as a Pay subscription.
|
|
4
|
+
#
|
|
5
|
+
# Columns hold the same meaning as for any other Pay processor:
|
|
6
|
+
#
|
|
7
|
+
# [processor_id] The Google purchase token.
|
|
8
|
+
# [processor_plan] The Google product id (the subscription id in the Play Console).
|
|
9
|
+
# [status] A Pay status, mapped from Google's state by STATUSES.
|
|
10
|
+
# [ends_at] Set once the subscription is canceled, expired or revoked.
|
|
11
|
+
# [object] The last response of the Google Play Developer API, for debugging.
|
|
12
|
+
#
|
|
13
|
+
# Google-specific details live in +data+: the raw +subscription_state+, the
|
|
14
|
+
# +base_plan_id+, +auto_renewing+ and +revoked_at+.
|
|
15
|
+
#
|
|
16
|
+
# Because statuses use Pay's vocabulary, Pay helpers behave exactly as they do for
|
|
17
|
+
# Stripe: +active?+, +past_due?+, +on_grace_period?+, +Pay::Subscription.active+, ...
|
|
18
|
+
class Subscription < Pay::Subscription
|
|
19
|
+
# Google +subscriptionState+ => Pay status.
|
|
20
|
+
#
|
|
21
|
+
# * In grace period, Google requires the customer to keep access, so it maps to
|
|
22
|
+
# "active". Use +in_grace_period?+ to show a "fix your payment" banner.
|
|
23
|
+
# * Canceled means "will not renew": access continues until +ends_at+, which is how
|
|
24
|
+
# Pay represents a canceled subscription that is still valid.
|
|
25
|
+
# * On hold means the payment failed after the grace period; access is suspended
|
|
26
|
+
# while Google retries, like a Stripe "past_due" subscription.
|
|
27
|
+
#
|
|
28
|
+
# See https://developer.android.com/google/play/billing/lifecycle/subscriptions
|
|
29
|
+
STATUSES = {
|
|
30
|
+
"SUBSCRIPTION_STATE_PENDING" => "incomplete",
|
|
31
|
+
"SUBSCRIPTION_STATE_ACTIVE" => "active",
|
|
32
|
+
"SUBSCRIPTION_STATE_IN_GRACE_PERIOD" => "active",
|
|
33
|
+
"SUBSCRIPTION_STATE_ON_HOLD" => "past_due",
|
|
34
|
+
"SUBSCRIPTION_STATE_PAUSED" => "paused",
|
|
35
|
+
"SUBSCRIPTION_STATE_CANCELED" => "active",
|
|
36
|
+
"SUBSCRIPTION_STATE_EXPIRED" => "canceled",
|
|
37
|
+
"SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED" => "incomplete_expired"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Google states after which the subscription ends at the expiry time of its period.
|
|
41
|
+
ENDING_STATES = %w[SUBSCRIPTION_STATE_CANCELED SUBSCRIPTION_STATE_EXPIRED].freeze
|
|
42
|
+
|
|
43
|
+
store_accessor :data, :subscription_state, :base_plan_id, :auto_renewing, :revoked_at
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Reads a purchase from Google (unless +object+ is given), saves it and emits the
|
|
47
|
+
# matching Tillbell event. Safe to call any number of times, in any order: the
|
|
48
|
+
# result only depends on Google's current state, and an event fires only when
|
|
49
|
+
# something changed.
|
|
50
|
+
#
|
|
51
|
+
# +revoked+ tells that Google refunded the purchase, which the API response alone
|
|
52
|
+
# does not distinguish from a normal expiry.
|
|
53
|
+
#
|
|
54
|
+
# Returns the subscription, or nil when the purchase was not made through Tillbell
|
|
55
|
+
# (no known +obfuscatedAccountId+).
|
|
56
|
+
def sync(purchase_token, object: nil, revoked: false)
|
|
57
|
+
object ||= Tillbell::GooglePlay.subscription(purchase_token)
|
|
58
|
+
|
|
59
|
+
pay_customer = Pay::GooglePlay::Customer.find_by(processor_id: Tillbell::GooglePlay.account_id(object))
|
|
60
|
+
if pay_customer.nil?
|
|
61
|
+
Rails.logger.warn "[Tillbell] Ignoring Google Play purchase #{Tillbell.redact(purchase_token)}: unknown account"
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
subscription, event = save_from_google(pay_customer, purchase_token, attributes_from(object, revoked: revoked))
|
|
66
|
+
Tillbell.instrument(event, subscription) if event
|
|
67
|
+
|
|
68
|
+
retire(object[:linked_purchase_token])
|
|
69
|
+
acknowledge(subscription, object)
|
|
70
|
+
subscription
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Maps a +subscriptionsv2+ response to subscription attributes. Pure function, no I/O.
|
|
74
|
+
def attributes_from(object, revoked: false)
|
|
75
|
+
state = object[:subscription_state]
|
|
76
|
+
line_item = object[:line_items].max_by { |item| item[:expiry_time].to_s }
|
|
77
|
+
product_id = line_item[:product_id]
|
|
78
|
+
base_plan_id = line_item.dig(:offer_details, :base_plan_id)
|
|
79
|
+
expires_at = line_item[:expiry_time]&.then { |time| Time.zone.parse(time) }
|
|
80
|
+
|
|
81
|
+
attributes = {
|
|
82
|
+
name: Tillbell.subscription_name.call(product_id: product_id, base_plan_id: base_plan_id),
|
|
83
|
+
processor_plan: product_id,
|
|
84
|
+
status: STATUSES.fetch(state) { raise Tillbell::Error, "Unknown Google Play subscription state #{state.inspect}" },
|
|
85
|
+
current_period_end: expires_at,
|
|
86
|
+
ends_at: (expires_at if ENDING_STATES.include?(state)),
|
|
87
|
+
object: object,
|
|
88
|
+
subscription_state: state,
|
|
89
|
+
base_plan_id: base_plan_id,
|
|
90
|
+
auto_renewing: line_item.dig(:auto_renewing_plan, :auto_renew_enabled) || false
|
|
91
|
+
}
|
|
92
|
+
attributes.merge!(status: "canceled", ends_at: Time.current, revoked_at: Time.current.iso8601) if revoked
|
|
93
|
+
attributes
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
# 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
|
|
99
|
+
# request and notification) serialize on the lock, so only one emits the event.
|
|
100
|
+
def save_from_google(pay_customer, purchase_token, attributes)
|
|
101
|
+
attempts = 0
|
|
102
|
+
begin
|
|
103
|
+
# A savepoint, so a failed insert never breaks a transaction the caller opened.
|
|
104
|
+
transaction(requires_new: true) do
|
|
105
|
+
subscription = pay_customer.subscriptions.lock.find_or_initialize_by(processor_id: purchase_token)
|
|
106
|
+
# A refund is final: later reads report a normal expiry (or, if Google is
|
|
107
|
+
# lagging, an active subscription), and a duplicated refund notification
|
|
108
|
+
# must not emit a second event.
|
|
109
|
+
subscription.assign_attributes(subscription.revoked? ? attributes.except(:status, :ends_at, :revoked_at) : attributes)
|
|
110
|
+
|
|
111
|
+
event = subscription.event_for_changes
|
|
112
|
+
subscription.save!
|
|
113
|
+
[ subscription, event ]
|
|
114
|
+
end
|
|
115
|
+
rescue ActiveRecord::RecordNotUnique
|
|
116
|
+
# Another process inserted the row first; a second attempt finds it and locks it.
|
|
117
|
+
attempts += 1
|
|
118
|
+
attempts == 1 ? retry : raise
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A plan change creates a new purchase token that points to the old one. The old
|
|
123
|
+
# subscription ends now; the new one carries the access.
|
|
124
|
+
def retire(linked_purchase_token)
|
|
125
|
+
return if linked_purchase_token.blank?
|
|
126
|
+
|
|
127
|
+
where(processor_id: linked_purchase_token).where.not(status: "canceled").find_each do |subscription|
|
|
128
|
+
subscription.update!(status: "canceled", ends_at: Time.current)
|
|
129
|
+
Tillbell.instrument(:subscription_expired, subscription)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Acknowledges the purchase once it grants access. The native app never does it.
|
|
134
|
+
def acknowledge(subscription, object)
|
|
135
|
+
return unless object[:acknowledgement_state] == "ACKNOWLEDGEMENT_STATE_PENDING" && subscription.active?
|
|
136
|
+
|
|
137
|
+
Tillbell::GooglePlay.acknowledge(subscription.processor_plan, subscription.processor_id)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The Tillbell event that describes the pending changes, or nil when nothing a host
|
|
142
|
+
# app cares about changed. Called before saving.
|
|
143
|
+
def event_for_changes
|
|
144
|
+
return :subscription_created if new_record?
|
|
145
|
+
return if (changed - %w[object updated_at]).none?
|
|
146
|
+
return :subscription_revoked if revoked_at_changed?
|
|
147
|
+
return :subscription_expired if status_changed? && status == "canceled"
|
|
148
|
+
return :subscription_canceled if ends_at_changed? && ends_at.present? && status == "active"
|
|
149
|
+
|
|
150
|
+
:subscription_updated
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# True while Google retries a failed payment and the customer keeps access.
|
|
154
|
+
def in_grace_period?
|
|
155
|
+
subscription_state == "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# True when the payment failed after the grace period and access is suspended.
|
|
159
|
+
def on_hold?
|
|
160
|
+
subscription_state == "SUBSCRIPTION_STATE_ON_HOLD"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# True when Google refunded the purchase.
|
|
164
|
+
def revoked?
|
|
165
|
+
revoked_at.present?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def paused?
|
|
169
|
+
status == "paused"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Stops the renewal with Google; access continues until the end of the period.
|
|
173
|
+
# A subscription that already stopped renewing is only refreshed: Google has nothing
|
|
174
|
+
# to cancel, and deleting an owner (Pay calls +cancel_now!+ on every subscription
|
|
175
|
+
# still granting access) must not fail on it.
|
|
176
|
+
def cancel(**)
|
|
177
|
+
Tillbell::GooglePlay.cancel(processor_plan, processor_id) if auto_renewing
|
|
178
|
+
sync!
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Google can only end a subscription immediately by refunding it, which Tillbell
|
|
182
|
+
# never does on its own. This stops the renewal instead, so deleting an owner (Pay
|
|
183
|
+
# calls +cancel_now!+ then) never leaves a subscription billing forever.
|
|
184
|
+
def cancel_now!(**)
|
|
185
|
+
cancel
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Plan changes, pauses and resumes happen in the app (plan change through the
|
|
189
|
+
# paywall) or in the Play Store.
|
|
190
|
+
%i[swap pause resume change_quantity].each do |method_name|
|
|
191
|
+
define_method(method_name) do |*, **|
|
|
192
|
+
raise Tillbell::Error, "Google Play subscriptions cannot #{method_name} from the server; use the app or the Play Store"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Texts of the paywall. Override any of them in your app's locale files.
|
|
2
|
+
en:
|
|
3
|
+
tillbell:
|
|
4
|
+
paywall:
|
|
5
|
+
submit: "Subscribe"
|
|
6
|
+
restore: "Restore purchases"
|
|
7
|
+
manage: "Manage subscription"
|
|
8
|
+
fallback: "Subscriptions are available in the latest version of the Android app."
|
|
9
|
+
# Status messages shown by the Stimulus controller.
|
|
10
|
+
messages:
|
|
11
|
+
verifying: "We are checking your purchase. This can take a few minutes."
|
|
12
|
+
pending: "Your payment is pending. Your subscription starts as soon as it is confirmed."
|
|
13
|
+
unavailable: "Google Play billing is not available on this device."
|
|
14
|
+
nothing_to_restore: "No purchase to restore."
|
|
15
|
+
rejected: "This purchase belongs to another account."
|
|
16
|
+
error: "Something went wrong. Please try again."
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Tillbell::Engine.routes.draw do
|
|
2
|
+
# Called by the paywall with the purchase token returned by the native app.
|
|
3
|
+
resources :purchases, only: :create
|
|
4
|
+
|
|
5
|
+
# Pub/Sub push endpoint for Google Play real-time developer notifications.
|
|
6
|
+
post "notifications/google_play", to: "notifications#create", as: :google_play_notifications
|
|
7
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/manage.request.json",
|
|
4
|
+
"title": "manage (page to native)",
|
|
5
|
+
"description": "Opens the Play Store subscription page (https://play.google.com/store/account/subscriptions, with sku and package when productId is set). No reply.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"productId"
|
|
9
|
+
],
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"productId": {
|
|
13
|
+
"type": [
|
|
14
|
+
"string",
|
|
15
|
+
"null"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"metadata": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"description": "Added by @hotwired/hotwire-native-bridge to every message sent by the page.",
|
|
21
|
+
"properties": {
|
|
22
|
+
"url": {
|
|
23
|
+
"type": "string"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/products.reply.json",
|
|
4
|
+
"title": "products (native to page)",
|
|
5
|
+
"description": "Plans known to Google Play, with the price formatted for the customer's locale. Plans missing from the list cannot be bought.",
|
|
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
|
+
"description": "unavailable: Google Play billing cannot be used on this device."
|
|
19
|
+
},
|
|
20
|
+
"products": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": [
|
|
25
|
+
"productId",
|
|
26
|
+
"basePlanId",
|
|
27
|
+
"formattedPrice"
|
|
28
|
+
],
|
|
29
|
+
"additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"productId": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"minLength": 1,
|
|
34
|
+
"description": "Google Play product id (subscription id)."
|
|
35
|
+
},
|
|
36
|
+
"basePlanId": {
|
|
37
|
+
"type": [
|
|
38
|
+
"string",
|
|
39
|
+
"null"
|
|
40
|
+
],
|
|
41
|
+
"description": "Google Play base plan id, or null for the product's only base plan."
|
|
42
|
+
},
|
|
43
|
+
"formattedPrice": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"minLength": 1,
|
|
46
|
+
"description": "ProductDetails formattedPrice of the base plan's regular pricing phase, e.g. \"\u20ac4.99\"."
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"message": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Developer-facing detail, never shown to the customer."
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"if": {
|
|
57
|
+
"properties": {
|
|
58
|
+
"result": {
|
|
59
|
+
"const": "ok"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"then": {
|
|
64
|
+
"required": [
|
|
65
|
+
"products"
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/products.request.json",
|
|
4
|
+
"title": "products (page to native)",
|
|
5
|
+
"description": "Asks the native app for the localized price of each plan shown by the paywall.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"products"
|
|
9
|
+
],
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"products": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": [
|
|
17
|
+
"productId",
|
|
18
|
+
"basePlanId"
|
|
19
|
+
],
|
|
20
|
+
"additionalProperties": false,
|
|
21
|
+
"properties": {
|
|
22
|
+
"productId": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"minLength": 1,
|
|
25
|
+
"description": "Google Play product id (subscription id)."
|
|
26
|
+
},
|
|
27
|
+
"basePlanId": {
|
|
28
|
+
"type": [
|
|
29
|
+
"string",
|
|
30
|
+
"null"
|
|
31
|
+
],
|
|
32
|
+
"description": "Google Play base plan id, or null for the product's only base plan."
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"metadata": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"description": "Added by @hotwired/hotwire-native-bridge to every message sent by the page.",
|
|
40
|
+
"properties": {
|
|
41
|
+
"url": {
|
|
42
|
+
"type": "string"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/purchase.reply.json",
|
|
4
|
+
"title": "purchase (native to page)",
|
|
5
|
+
"description": "Outcome of the purchase sheet. The native app never acknowledges the purchase: the server does after verifying it.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"result"
|
|
9
|
+
],
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {
|
|
12
|
+
"result": {
|
|
13
|
+
"enum": [
|
|
14
|
+
"purchased",
|
|
15
|
+
"pending",
|
|
16
|
+
"canceled",
|
|
17
|
+
"already_owned",
|
|
18
|
+
"unavailable",
|
|
19
|
+
"error"
|
|
20
|
+
],
|
|
21
|
+
"description": "purchased: Purchase.PurchaseState.PURCHASED. pending: PurchaseState.PENDING (e.g. cash payment). canceled: USER_CANCELED. already_owned: ITEM_ALREADY_OWNED. unavailable: BILLING_UNAVAILABLE, SERVICE_UNAVAILABLE or FEATURE_NOT_SUPPORTED. error: anything else."
|
|
22
|
+
},
|
|
23
|
+
"productId": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"minLength": 1,
|
|
26
|
+
"description": "Google Play product id (subscription id)."
|
|
27
|
+
},
|
|
28
|
+
"purchaseToken": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1,
|
|
31
|
+
"description": "Purchase.getPurchaseToken()."
|
|
32
|
+
},
|
|
33
|
+
"message": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Developer-facing detail, never shown to the customer."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"if": {
|
|
39
|
+
"properties": {
|
|
40
|
+
"result": {
|
|
41
|
+
"enum": [
|
|
42
|
+
"purchased",
|
|
43
|
+
"pending"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"then": {
|
|
49
|
+
"required": [
|
|
50
|
+
"productId",
|
|
51
|
+
"purchaseToken"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/pmakerhq/tillbell-rails/blob/main/contract/purchase.request.json",
|
|
4
|
+
"title": "purchase (page to native)",
|
|
5
|
+
"description": "Opens the Google Play purchase sheet. With oldPurchaseToken, the purchase replaces an existing subscription (plan change).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"productId",
|
|
9
|
+
"basePlanId",
|
|
10
|
+
"obfuscatedAccountId"
|
|
11
|
+
],
|
|
12
|
+
"additionalProperties": false,
|
|
13
|
+
"properties": {
|
|
14
|
+
"productId": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"minLength": 1,
|
|
17
|
+
"description": "Google Play product id (subscription id)."
|
|
18
|
+
},
|
|
19
|
+
"basePlanId": {
|
|
20
|
+
"type": [
|
|
21
|
+
"string",
|
|
22
|
+
"null"
|
|
23
|
+
],
|
|
24
|
+
"description": "Google Play base plan id, or null for the product's only base plan."
|
|
25
|
+
},
|
|
26
|
+
"obfuscatedAccountId": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1,
|
|
29
|
+
"maxLength": 64,
|
|
30
|
+
"description": "Pass unchanged to BillingFlowParams.Builder.setObfuscatedAccountId. Generated by the server; never derive it on the device."
|
|
31
|
+
},
|
|
32
|
+
"oldPurchaseToken": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"minLength": 1,
|
|
35
|
+
"description": "Purchase token of the subscription being replaced (SubscriptionUpdateParams.Builder.setOldPurchaseToken)."
|
|
36
|
+
},
|
|
37
|
+
"replacementMode": {
|
|
38
|
+
"enum": [
|
|
39
|
+
"WITH_TIME_PRORATION",
|
|
40
|
+
"CHARGE_PRORATED_PRICE",
|
|
41
|
+
"WITHOUT_PRORATION",
|
|
42
|
+
"CHARGE_FULL_PRICE",
|
|
43
|
+
"DEFERRED"
|
|
44
|
+
],
|
|
45
|
+
"description": "SubscriptionUpdateParams.ReplacementMode, chosen by the server and passed through."
|
|
46
|
+
},
|
|
47
|
+
"metadata": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"description": "Added by @hotwired/hotwire-native-bridge to every message sent by the page.",
|
|
50
|
+
"properties": {
|
|
51
|
+
"url": {
|
|
52
|
+
"type": "string"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"dependentRequired": {
|
|
58
|
+
"oldPurchaseToken": [
|
|
59
|
+
"replacementMode"
|
|
60
|
+
],
|
|
61
|
+
"replacementMode": [
|
|
62
|
+
"oldPurchaseToken"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|