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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +341 -0
  5. data/app/controllers/tillbell/notifications_controller.rb +71 -0
  6. data/app/controllers/tillbell/purchases_controller.rb +44 -0
  7. data/app/helpers/tillbell/paywall_builder.rb +81 -0
  8. data/app/helpers/tillbell/paywall_helper.rb +40 -0
  9. data/app/jobs/tillbell/sync_job.rb +21 -0
  10. data/app/models/pay/google_play/customer.rb +52 -0
  11. data/app/models/pay/google_play/subscription.rb +197 -0
  12. data/config/locales/en.yml +16 -0
  13. data/config/routes.rb +7 -0
  14. data/contract/examples/manage.request.json +3 -0
  15. data/contract/examples/products.reply.ok.json +15 -0
  16. data/contract/examples/products.reply.unavailable.json +4 -0
  17. data/contract/examples/products.request.json +15 -0
  18. data/contract/examples/purchase.reply.already_owned.json +3 -0
  19. data/contract/examples/purchase.reply.canceled.json +3 -0
  20. data/contract/examples/purchase.reply.error.json +4 -0
  21. data/contract/examples/purchase.reply.pending.json +5 -0
  22. data/contract/examples/purchase.reply.purchased.json +5 -0
  23. data/contract/examples/purchase.reply.unavailable.json +3 -0
  24. data/contract/examples/purchase.request.json +8 -0
  25. data/contract/examples/purchase.request.plan_change.json +7 -0
  26. data/contract/examples/restore.reply.empty.json +4 -0
  27. data/contract/examples/restore.reply.ok.json +9 -0
  28. data/contract/examples/restore.request.json +5 -0
  29. data/contract/manage.request.json +28 -0
  30. data/contract/products.reply.json +68 -0
  31. data/contract/products.request.json +47 -0
  32. data/contract/purchase.reply.json +54 -0
  33. data/contract/purchase.request.json +65 -0
  34. data/contract/restore.reply.json +58 -0
  35. data/contract/restore.request.json +19 -0
  36. data/lib/generators/tillbell/install/install_generator.rb +34 -0
  37. data/lib/generators/tillbell/install/templates/initializer.rb +33 -0
  38. data/lib/generators/tillbell/install/templates/tillbell_controller.js +147 -0
  39. data/lib/tillbell/engine.rb +42 -0
  40. data/lib/tillbell/google_play.rb +110 -0
  41. data/lib/tillbell/version.rb +3 -0
  42. data/lib/tillbell.rb +91 -0
  43. 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,3 @@
1
+ {
2
+ "productId": "premium"
3
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "result": "ok",
3
+ "products": [
4
+ {
5
+ "productId": "premium",
6
+ "basePlanId": "monthly",
7
+ "formattedPrice": "€4.99"
8
+ },
9
+ {
10
+ "productId": "premium",
11
+ "basePlanId": "yearly",
12
+ "formattedPrice": "€49.99"
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "result": "unavailable",
3
+ "message": "BILLING_UNAVAILABLE"
4
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "products": [
3
+ {
4
+ "productId": "premium",
5
+ "basePlanId": "monthly"
6
+ },
7
+ {
8
+ "productId": "premium",
9
+ "basePlanId": "yearly"
10
+ }
11
+ ],
12
+ "metadata": {
13
+ "url": "https://example.com/paywall"
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "result": "already_owned"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "result": "canceled"
3
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "result": "error",
3
+ "message": "DEVELOPER_ERROR"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "result": "pending",
3
+ "productId": "premium",
4
+ "purchaseToken": "purchase-token"
5
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "result": "purchased",
3
+ "productId": "premium",
4
+ "purchaseToken": "purchase-token"
5
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "result": "unavailable"
3
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "productId": "premium",
3
+ "basePlanId": "monthly",
4
+ "obfuscatedAccountId": "6f1c2a8e-3b7d-4e59-9a0c-2d4f8b1e7c35",
5
+ "metadata": {
6
+ "url": "https://example.com/paywall"
7
+ }
8
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "productId": "premium",
3
+ "basePlanId": "yearly",
4
+ "obfuscatedAccountId": "6f1c2a8e-3b7d-4e59-9a0c-2d4f8b1e7c35",
5
+ "oldPurchaseToken": "old-purchase-token",
6
+ "replacementMode": "CHARGE_PRORATED_PRICE"
7
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "result": "ok",
3
+ "purchases": []
4
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "result": "ok",
3
+ "purchases": [
4
+ {
5
+ "productId": "premium",
6
+ "purchaseToken": "purchase-token"
7
+ }
8
+ ]
9
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "metadata": {
3
+ "url": "https://example.com/paywall"
4
+ }
5
+ }
@@ -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
+ }