mkit-cli 0.3.1__py3-none-any.whl
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.
- mkit/__init__.py +5 -0
- mkit/art.py +1220 -0
- mkit/aso.py +359 -0
- mkit/cli.py +176 -0
- mkit/cloud/__init__.py +1 -0
- mkit/cloud/auth.py +130 -0
- mkit/cloud/fbproject.py +180 -0
- mkit/cloud/firebase.py +164 -0
- mkit/cloud/insight.py +286 -0
- mkit/cloud/money.py +676 -0
- mkit/cloud/play.py +497 -0
- mkit/cloud/store.py +390 -0
- mkit/commands/__init__.py +2 -0
- mkit/commands/art.py +116 -0
- mkit/commands/aso.py +130 -0
- mkit/commands/dev.py +256 -0
- mkit/commands/doctor.py +191 -0
- mkit/commands/firebase_cmds.py +194 -0
- mkit/commands/init.py +213 -0
- mkit/commands/insight.py +110 -0
- mkit/commands/meta.py +84 -0
- mkit/commands/money.py +152 -0
- mkit/commands/release.py +430 -0
- mkit/commands/selfcmd.py +167 -0
- mkit/commands/store.py +263 -0
- mkit/config.py +146 -0
- mkit/data/art/art.toml +119 -0
- mkit/data/art/en.json +38 -0
- mkit/data/datasafety-questions.csv +783 -0
- mkit/datasafety.py +302 -0
- mkit/device.py +246 -0
- mkit/docgen.py +100 -0
- mkit/errors.py +78 -0
- mkit/git.py +95 -0
- mkit/hooks.py +32 -0
- mkit/kinds.py +350 -0
- mkit/notify.py +49 -0
- mkit/output.py +377 -0
- mkit/proc.py +154 -0
- mkit/registry.py +99 -0
- mkit/safety.py +60 -0
- mkit/secrets.py +148 -0
- mkit/toolchain.py +210 -0
- mkit_cli-0.3.1.dist-info/METADATA +179 -0
- mkit_cli-0.3.1.dist-info/RECORD +48 -0
- mkit_cli-0.3.1.dist-info/WHEEL +4 -0
- mkit_cli-0.3.1.dist-info/entry_points.txt +2 -0
- mkit_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
mkit/cloud/money.py
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"""In-app products and subscriptions, described in a file and applied to Play.
|
|
2
|
+
|
|
3
|
+
The file is [store] products in mkit.toml (e.g. store/products.toml):
|
|
4
|
+
|
|
5
|
+
regions_version = "2022/02" # pinned: a region Google adds later does not re-price anything
|
|
6
|
+
|
|
7
|
+
[pricing] # how an anchor USD price becomes local prices
|
|
8
|
+
pin_nominal = ["USD", "EUR", "GBP"] # 4.99 stays 4.99 in these
|
|
9
|
+
whole_unit = ["INR", "BDT"] # prices like 499, not 4.99
|
|
10
|
+
[pricing.rates] # units per USD, refresh by hand
|
|
11
|
+
EUR = 0.86
|
|
12
|
+
INR = 94.79
|
|
13
|
+
[pricing.regions] # currency -> Play region codes set explicitly
|
|
14
|
+
EUR = ["DE", "FR"]
|
|
15
|
+
INR = ["IN"]
|
|
16
|
+
|
|
17
|
+
[[product]] # one-time product
|
|
18
|
+
id = "pro_lifetime"
|
|
19
|
+
usd = 4.99
|
|
20
|
+
title = "Pro"
|
|
21
|
+
description = "One payment, Pro forever."
|
|
22
|
+
|
|
23
|
+
[[product.offer]] # a discount offer on that product's purchase option
|
|
24
|
+
id = "launch" # offerId: lower-case letters, digits, hyphens
|
|
25
|
+
discount_pct = 50 # 1..99, the same relative discount in every region it sells in
|
|
26
|
+
start = "2026-11-01" # optional; UTC midnight
|
|
27
|
+
end = "2026-12-31" # required
|
|
28
|
+
tags = ["launch"] # optional offer tags for the billing library
|
|
29
|
+
purchase_option = "buy" # optional; default: the product's own (single/legacy) option
|
|
30
|
+
|
|
31
|
+
[[subscription]]
|
|
32
|
+
id = "pro_monthly"
|
|
33
|
+
base_plan = "monthly"
|
|
34
|
+
period = "P1M"
|
|
35
|
+
usd = 0.99
|
|
36
|
+
grace = "P7D"
|
|
37
|
+
account_hold = "P30D"
|
|
38
|
+
title = "Pro, monthly"
|
|
39
|
+
description = "Cancel any time."
|
|
40
|
+
benefits = ["Unlimited layouts"]
|
|
41
|
+
|
|
42
|
+
Every price is the smallest amount ending in 99 that is worth at least the
|
|
43
|
+
anchor: rounding up is what makes "never below the dollar price" true by
|
|
44
|
+
construction. Regions not listed get Play's own conversion from the USD/EUR
|
|
45
|
+
anchor. apply never deletes anything: not products, not offers.
|
|
46
|
+
|
|
47
|
+
Offers are created in DRAFT and then activated, like purchase options. An
|
|
48
|
+
offer is sold in every region its purchase option is available in, at the
|
|
49
|
+
same relative discount.
|
|
50
|
+
|
|
51
|
+
Uses monetization.onetimeproducts (the legacy inappproducts endpoint is
|
|
52
|
+
refused for new work), monetization.onetimeproducts.purchaseOptions.offers
|
|
53
|
+
and monetization.subscriptions.
|
|
54
|
+
"""
|
|
55
|
+
import datetime as _dt
|
|
56
|
+
import re
|
|
57
|
+
import tomllib
|
|
58
|
+
|
|
59
|
+
from ..errors import Conflict, UsageError
|
|
60
|
+
from . import auth
|
|
61
|
+
|
|
62
|
+
DEFAULT_REGIONS_VERSION = '2022/02'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load(project):
|
|
66
|
+
path = project.path('store.products')
|
|
67
|
+
if not path or not path.is_file():
|
|
68
|
+
raise UsageError(f'products file not found ({path or "set products under [store]"})',
|
|
69
|
+
code='NO_PRODUCTS')
|
|
70
|
+
with open(path, 'rb') as f:
|
|
71
|
+
data = tomllib.load(f)
|
|
72
|
+
data.setdefault('pricing', {})
|
|
73
|
+
for kind in ('product', 'subscription'):
|
|
74
|
+
for item in data.get(kind, []):
|
|
75
|
+
for key in ('id', 'usd', 'title'):
|
|
76
|
+
if key not in item:
|
|
77
|
+
raise UsageError(f'{path.name}: a [[{kind}]] has no {key}', code='BAD_PRODUCTS')
|
|
78
|
+
for item in data.get('product', []):
|
|
79
|
+
_check_offers(path.name, item)
|
|
80
|
+
for item in data.get('subscription', []):
|
|
81
|
+
if 'offer' in item:
|
|
82
|
+
raise UsageError(f'{path.name}: [[subscription.offer]] is not supported; offers are for '
|
|
83
|
+
f'one-time products ([[product.offer]]); a free trial is `trial = "P7D"`',
|
|
84
|
+
code='BAD_PRODUCTS')
|
|
85
|
+
if 'trial' in item and not TRIAL_RE.match(str(item['trial'])):
|
|
86
|
+
raise UsageError(f'{path.name}: {item["id"]}: trial {item["trial"]!r} is not a duration '
|
|
87
|
+
f'like "P3D", "P1W" or "P1M"', code='BAD_PRODUCTS')
|
|
88
|
+
if 'trial_id' in item and not ID_RE.match(str(item['trial_id'])):
|
|
89
|
+
raise UsageError(f'{path.name}: {item["id"]}: trial_id {item["trial_id"]!r}: a-z, 0-9 and '
|
|
90
|
+
f'hyphens, starting with a letter or digit', code='BAD_PRODUCTS')
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# offerId and purchaseOptionId: Play's own rule, up to 63 characters.
|
|
95
|
+
ID_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,62}$')
|
|
96
|
+
# Offer tags: RFC-1034 style, up to 20 characters.
|
|
97
|
+
TAG_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,19}$')
|
|
98
|
+
OFFER_KEYS = {'id', 'discount_pct', 'start', 'end', 'tags', 'purchase_option'}
|
|
99
|
+
# A free trial's length, as Play takes it: days, weeks or months.
|
|
100
|
+
TRIAL_RE = re.compile(r'^P[1-9][0-9]{0,2}[DWM]$')
|
|
101
|
+
TRIAL_ID = 'free-trial'
|
|
102
|
+
TRIAL_MASK = 'phases,regionalConfigs,otherRegionsConfig,targeting,offerTags'
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_time(value):
|
|
106
|
+
"""A products-file date or datetime as an aware UTC datetime.
|
|
107
|
+
"2026-12-31" (or an unquoted TOML date) is UTC midnight."""
|
|
108
|
+
if isinstance(value, _dt.datetime):
|
|
109
|
+
moment = value
|
|
110
|
+
elif isinstance(value, _dt.date):
|
|
111
|
+
moment = _dt.datetime(value.year, value.month, value.day)
|
|
112
|
+
elif isinstance(value, str):
|
|
113
|
+
text = value.strip()
|
|
114
|
+
if re.fullmatch(r'\d{4}-\d{2}-\d{2}', text):
|
|
115
|
+
moment = _dt.datetime.fromisoformat(text)
|
|
116
|
+
else:
|
|
117
|
+
moment = _dt.datetime.fromisoformat(text.replace('Z', '+00:00'))
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(value)
|
|
120
|
+
if moment.tzinfo is None:
|
|
121
|
+
moment = moment.replace(tzinfo=_dt.timezone.utc)
|
|
122
|
+
return moment.astimezone(_dt.timezone.utc)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def rfc3339(moment):
|
|
126
|
+
return moment.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_offers(name, item):
|
|
130
|
+
"""Refuse a malformed [[product.offer]] before anything talks to Play."""
|
|
131
|
+
offers = item.get('offer', [])
|
|
132
|
+
if not isinstance(offers, list):
|
|
133
|
+
raise UsageError(f'{name}: product {item["id"]}: write offers as [[product.offer]] tables',
|
|
134
|
+
code='BAD_PRODUCTS')
|
|
135
|
+
seen = set()
|
|
136
|
+
for offer in offers:
|
|
137
|
+
where = f'{name}: product {item["id"]}, offer {offer.get("id", "?")}'
|
|
138
|
+
|
|
139
|
+
def bad(problem):
|
|
140
|
+
return UsageError(f'{where}: {problem}', code='BAD_PRODUCTS')
|
|
141
|
+
unknown = sorted(set(offer) - OFFER_KEYS)
|
|
142
|
+
if unknown:
|
|
143
|
+
raise bad(f'unknown key {", ".join(unknown)} (allowed: {", ".join(sorted(OFFER_KEYS))})')
|
|
144
|
+
oid = offer.get('id')
|
|
145
|
+
if not isinstance(oid, str) or not ID_RE.match(oid):
|
|
146
|
+
raise bad('id must start with a lower-case letter or digit and use only a-z, 0-9 and '
|
|
147
|
+
'hyphens, up to 63 characters')
|
|
148
|
+
pct = offer.get('discount_pct')
|
|
149
|
+
if isinstance(pct, bool) or not isinstance(pct, (int, float)) or not 1 <= pct <= 99:
|
|
150
|
+
raise bad('discount_pct must be a number from 1 to 99 (percent off)')
|
|
151
|
+
if 'end' not in offer:
|
|
152
|
+
raise bad('end is required for a discount offer ("YYYY-MM-DD", UTC midnight)')
|
|
153
|
+
times = {}
|
|
154
|
+
for key in ('start', 'end'):
|
|
155
|
+
if key in offer:
|
|
156
|
+
try:
|
|
157
|
+
times[key] = parse_time(offer[key])
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
raise bad(f'{key} = {offer[key]!r} is not a date ("YYYY-MM-DD") or ISO datetime') from None
|
|
160
|
+
if 'start' in times and times['end'] <= times['start']:
|
|
161
|
+
raise bad('end must be after start')
|
|
162
|
+
tags = offer.get('tags', [])
|
|
163
|
+
if not isinstance(tags, list) or len(tags) > 20 or not all(
|
|
164
|
+
isinstance(t, str) and TAG_RE.match(t) for t in tags):
|
|
165
|
+
raise bad('tags must be a list of up to 20 tags of a-z, 0-9 and hyphens, 20 characters each')
|
|
166
|
+
option = offer.get('purchase_option')
|
|
167
|
+
if option is not None and (not isinstance(option, str) or not ID_RE.match(option)):
|
|
168
|
+
raise bad('purchase_option must be a purchase option id (a-z, 0-9, hyphens)')
|
|
169
|
+
key = (option, oid)
|
|
170
|
+
if key in seen:
|
|
171
|
+
raise bad('the same offer id appears twice')
|
|
172
|
+
seen.add(key)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def price_for(pricing, currency, usd):
|
|
176
|
+
"""The smallest price in `currency` ending in 99 worth at least `usd`."""
|
|
177
|
+
if currency in pricing.get('pin_nominal', ['USD', 'EUR', 'GBP']):
|
|
178
|
+
return round(usd, 2)
|
|
179
|
+
rates = pricing.get('rates', {})
|
|
180
|
+
if currency not in rates:
|
|
181
|
+
raise UsageError(f'no rate for {currency} under [pricing.rates]', code='BAD_PRODUCTS')
|
|
182
|
+
want = usd * rates[currency]
|
|
183
|
+
if currency in pricing.get('whole_unit', []):
|
|
184
|
+
hundreds = 0
|
|
185
|
+
while hundreds * 100 + 99 < want:
|
|
186
|
+
hundreds += 1
|
|
187
|
+
return float(hundreds * 100 + 99)
|
|
188
|
+
units = 0
|
|
189
|
+
while units + 0.99 < want - 1e-9:
|
|
190
|
+
units += 1
|
|
191
|
+
return round(units + 0.99, 2)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def money(currency, amount):
|
|
195
|
+
units = int(amount)
|
|
196
|
+
return {'currencyCode': currency, 'units': str(units), 'nanos': int(round((amount - units) * 1e9))}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def amount_of(m):
|
|
200
|
+
return round(int(m.get('units', 0) or 0) + (m.get('nanos', 0) or 0) / 1e9, 2)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def regional_prices(pricing, usd):
|
|
204
|
+
"""{region: (currency, amount)} for every explicitly listed region."""
|
|
205
|
+
out = {}
|
|
206
|
+
for currency, regions in pricing.get('regions', {}).items():
|
|
207
|
+
amount = price_for(pricing, currency, usd)
|
|
208
|
+
for region in regions:
|
|
209
|
+
out[region] = (currency, amount)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _listings(item):
|
|
214
|
+
if 'listings' in item:
|
|
215
|
+
return [dict(languageCode=lang, **v) for lang, v in item['listings'].items()]
|
|
216
|
+
listing = {'languageCode': item.get('language', 'en-US'), 'title': item['title'],
|
|
217
|
+
'description': item.get('description', '')}
|
|
218
|
+
if item.get('benefits'):
|
|
219
|
+
listing['benefits'] = item['benefits']
|
|
220
|
+
return [listing]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def product_body(package, pricing, item):
|
|
224
|
+
usd_anchor = price_for(pricing, 'USD', item['usd'])
|
|
225
|
+
eur_anchor = price_for(pricing, 'EUR', item['usd'])
|
|
226
|
+
return {
|
|
227
|
+
'packageName': package, 'productId': item['id'], 'listings': _listings(item),
|
|
228
|
+
'purchaseOptions': [{
|
|
229
|
+
'purchaseOptionId': item.get('option', 'buy'), 'state': 'ACTIVE',
|
|
230
|
+
# Legacy-compatible so an older Billing Library still sees a buyable product.
|
|
231
|
+
'buyOption': {'legacyCompatible': True, 'multiQuantityEnabled': False},
|
|
232
|
+
'regionalPricingAndAvailabilityConfigs': [
|
|
233
|
+
{'regionCode': r, 'price': money(c, a), 'availability': 'AVAILABLE'}
|
|
234
|
+
for r, (c, a) in sorted(regional_prices(pricing, item['usd']).items())],
|
|
235
|
+
# Without this, a market Google adds later is silently unbuyable.
|
|
236
|
+
'newRegionsConfig': {'usdPrice': money('USD', usd_anchor), 'eurPrice': money('EUR', eur_anchor),
|
|
237
|
+
'availability': 'AVAILABLE'},
|
|
238
|
+
}],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def subscription_body(pricing, item):
|
|
243
|
+
usd_anchor = price_for(pricing, 'USD', item['usd'])
|
|
244
|
+
eur_anchor = price_for(pricing, 'EUR', item['usd'])
|
|
245
|
+
return {
|
|
246
|
+
'productId': item['id'], 'listings': _listings(item),
|
|
247
|
+
'basePlans': [{
|
|
248
|
+
'basePlanId': item.get('base_plan', 'monthly'), 'state': 'ACTIVE',
|
|
249
|
+
'autoRenewingBasePlanType': {
|
|
250
|
+
'billingPeriodDuration': item.get('period', 'P1M'),
|
|
251
|
+
'gracePeriodDuration': item.get('grace', 'P7D'),
|
|
252
|
+
'accountHoldDuration': item.get('account_hold', 'P30D'),
|
|
253
|
+
'resubscribeState': 'RESUBSCRIBE_STATE_ACTIVE',
|
|
254
|
+
'prorationMode': 'SUBSCRIPTION_PRORATION_MODE_CHARGE_ON_NEXT_BILLING_DATE',
|
|
255
|
+
'legacyCompatible': True,
|
|
256
|
+
},
|
|
257
|
+
'regionalConfigs': [{'regionCode': r, 'price': money(c, a), 'newSubscriberAvailability': True}
|
|
258
|
+
for r, (c, a) in sorted(regional_prices(pricing, item['usd']).items())],
|
|
259
|
+
'otherRegionsConfig': {'usdPrice': money('USD', usd_anchor), 'eurPrice': money('EUR', eur_anchor),
|
|
260
|
+
'newSubscriberAvailability': True},
|
|
261
|
+
}],
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def trial_body(package, item, base):
|
|
266
|
+
"""The free trial on a subscription's base plan: free for `trial`, then the
|
|
267
|
+
base plan's price, offered in every region the base plan is sold in, once
|
|
268
|
+
per person across all of this app's subscriptions (a trial on monthly and
|
|
269
|
+
on yearly is still one trial)."""
|
|
270
|
+
plan_id = base['basePlanId']
|
|
271
|
+
regions = [c['regionCode'] for c in base['regionalConfigs']]
|
|
272
|
+
return {
|
|
273
|
+
'packageName': package, 'productId': item['id'], 'basePlanId': plan_id,
|
|
274
|
+
'offerId': item.get('trial_id', TRIAL_ID), 'offerTags': [{'tag': 'trial'}],
|
|
275
|
+
'phases': [{'recurrenceCount': 1, 'duration': item['trial'],
|
|
276
|
+
'regionalConfigs': [{'regionCode': r, 'free': {}} for r in regions],
|
|
277
|
+
'otherRegionsConfig': {'free': {}}}],
|
|
278
|
+
'regionalConfigs': [{'regionCode': r, 'newSubscriberAvailability': True} for r in regions],
|
|
279
|
+
'otherRegionsConfig': {'otherRegionsNewSubscriberAvailability': True},
|
|
280
|
+
'targeting': {'acquisitionRule': {'scope': {'anySubscriptionInApp': {}}}},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _remote_trial(svc, package, product_id, plan_id, offer_id):
|
|
285
|
+
from googleapiclient.errors import HttpError
|
|
286
|
+
offers = svc.monetization().subscriptions().basePlans().offers()
|
|
287
|
+
try:
|
|
288
|
+
return offers.get(packageName=package, productId=product_id, basePlanId=plan_id,
|
|
289
|
+
offerId=offer_id).execute()
|
|
290
|
+
except HttpError as e:
|
|
291
|
+
if auth.status_of(e) == 404:
|
|
292
|
+
return None
|
|
293
|
+
raise
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _plan_trial(svc, package, item, body, exists):
|
|
297
|
+
"""The trial's change, if any: create, change its length, or activate."""
|
|
298
|
+
if 'trial' not in item:
|
|
299
|
+
return []
|
|
300
|
+
base = body['basePlans'][0]
|
|
301
|
+
trial = trial_body(package, item, base)
|
|
302
|
+
current = _remote_trial(svc, package, item['id'], base['basePlanId'], trial['offerId']) if exists else None
|
|
303
|
+
change = {'kind': 'trial', 'id': f'{item["id"]}/{trial["offerId"]}', 'product': item['id'],
|
|
304
|
+
'plan': base['basePlanId'], 'offer': trial['offerId'], 'trial': item['trial'], 'body': trial}
|
|
305
|
+
if current is None:
|
|
306
|
+
return [dict(change, action='create')]
|
|
307
|
+
have = [p.get('duration') for p in current.get('phases', [])]
|
|
308
|
+
if have != [item['trial']]:
|
|
309
|
+
return [dict(change, action='update', state=current.get('state'), **{'from': have[0] if have else None})]
|
|
310
|
+
if current.get('state') != 'ACTIVE':
|
|
311
|
+
return [dict(change, action='activate', state=current.get('state'))]
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _apply_trial(svc, package, version, c):
|
|
316
|
+
offers = svc.monetization().subscriptions().basePlans().offers()
|
|
317
|
+
ids = dict(packageName=package, productId=c['product'], basePlanId=c['plan'], offerId=c['offer'])
|
|
318
|
+
if c['action'] == 'create':
|
|
319
|
+
auth.retry(lambda: offers.create(regionsVersion_version=version, body=c['body'], **ids), c['id'])
|
|
320
|
+
elif c['action'] == 'update':
|
|
321
|
+
auth.retry(lambda: offers.patch(updateMask=TRIAL_MASK, regionsVersion_version=version,
|
|
322
|
+
body=c['body'], **ids), c['id'])
|
|
323
|
+
auth.retry(lambda: offers.activate(body=dict(ids), **ids), f'activate {c["id"]}')
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get_product(svc, package, pid):
|
|
327
|
+
from googleapiclient.errors import HttpError
|
|
328
|
+
try:
|
|
329
|
+
return svc.monetization().onetimeproducts().get(packageName=package, productId=pid).execute()
|
|
330
|
+
except HttpError as e:
|
|
331
|
+
if auth.status_of(e) == 404:
|
|
332
|
+
return None
|
|
333
|
+
raise
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _subscriptions(svc, package):
|
|
337
|
+
out, token = {}, None
|
|
338
|
+
while True:
|
|
339
|
+
resp = svc.monetization().subscriptions().list(packageName=package, pageToken=token).execute()
|
|
340
|
+
for s in resp.get('subscriptions', []):
|
|
341
|
+
out[s['productId']] = s
|
|
342
|
+
token = resp.get('nextPageToken')
|
|
343
|
+
if not token:
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def list_remote(svc, package):
|
|
348
|
+
products, token = [], None
|
|
349
|
+
while True:
|
|
350
|
+
resp = svc.monetization().onetimeproducts().list(packageName=package, pageToken=token).execute()
|
|
351
|
+
products += resp.get('oneTimeProducts', [])
|
|
352
|
+
token = resp.get('nextPageToken')
|
|
353
|
+
if not token:
|
|
354
|
+
break
|
|
355
|
+
return products, list(_subscriptions(svc, package).values())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _price_diff(current_configs, desired_configs, key):
|
|
359
|
+
have = {c['regionCode']: amount_of(c.get('price', {})) for c in current_configs or []}
|
|
360
|
+
want = {c['regionCode']: amount_of(c['price']) for c in desired_configs}
|
|
361
|
+
return {r: {'from': have.get(r), 'to': a} for r, a in want.items() if have.get(r) != a}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------------ offers
|
|
365
|
+
|
|
366
|
+
OFFER_MASK = 'offerTags,regionalPricingAndAvailabilityConfigs,discountedOffer'
|
|
367
|
+
# A purchase option region an offer can be sold in.
|
|
368
|
+
OFFERABLE = ('AVAILABLE', 'AVAILABLE_FOR_OFFERS_ONLY')
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _offer_option(item, body, current):
|
|
372
|
+
"""The purchase option an offer attaches to: the one the offer names, else
|
|
373
|
+
the one this file manages when Play has it (or is about to), else Play's
|
|
374
|
+
only option, else its legacy-compatible buy option."""
|
|
375
|
+
managed = body['purchaseOptions'][0]['purchaseOptionId']
|
|
376
|
+
options = (current or {}).get('purchaseOptions', []) or []
|
|
377
|
+
ids = [o.get('purchaseOptionId') for o in options]
|
|
378
|
+
if 'option' in item or not options or managed in ids:
|
|
379
|
+
return managed
|
|
380
|
+
if len(options) == 1:
|
|
381
|
+
return ids[0]
|
|
382
|
+
legacy = [o.get('purchaseOptionId') for o in options
|
|
383
|
+
if (o.get('buyOption') or {}).get('legacyCompatible')]
|
|
384
|
+
return legacy[0] if legacy else managed
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _offer_regions(option_id, body, current):
|
|
388
|
+
"""Every region the purchase option sells in, after this apply."""
|
|
389
|
+
regions = set()
|
|
390
|
+
for o in (current or {}).get('purchaseOptions', []) or []:
|
|
391
|
+
if o.get('purchaseOptionId') == option_id:
|
|
392
|
+
regions |= {c['regionCode'] for c in o.get('regionalPricingAndAvailabilityConfigs', []) or []
|
|
393
|
+
if c.get('availability') in OFFERABLE}
|
|
394
|
+
desired = body['purchaseOptions'][0]
|
|
395
|
+
if desired['purchaseOptionId'] == option_id:
|
|
396
|
+
regions |= {c['regionCode'] for c in desired['regionalPricingAndAvailabilityConfigs']}
|
|
397
|
+
return sorted(regions)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def offer_body(package, product_id, option_id, offer, regions):
|
|
401
|
+
discount = round(offer['discount_pct'] / 100, 4)
|
|
402
|
+
window = {'endTime': rfc3339(parse_time(offer['end']))}
|
|
403
|
+
if 'start' in offer:
|
|
404
|
+
window['startTime'] = rfc3339(parse_time(offer['start']))
|
|
405
|
+
return {
|
|
406
|
+
'packageName': package, 'productId': product_id, 'purchaseOptionId': option_id,
|
|
407
|
+
'offerId': offer['id'],
|
|
408
|
+
'offerTags': [{'tag': t} for t in offer.get('tags', [])],
|
|
409
|
+
# relativeDiscount is the fraction off: 0.5 turns $12 into $6.
|
|
410
|
+
'regionalPricingAndAvailabilityConfigs': [
|
|
411
|
+
{'regionCode': r, 'relativeDiscount': discount, 'availability': 'AVAILABLE'} for r in regions],
|
|
412
|
+
'discountedOffer': window,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _remote_offers(svc, package, product_id, option_id):
|
|
417
|
+
offers = svc.monetization().onetimeproducts().purchaseOptions().offers()
|
|
418
|
+
out, token = {}, None
|
|
419
|
+
while True:
|
|
420
|
+
resp = offers.list(packageName=package, productId=product_id, purchaseOptionId=option_id,
|
|
421
|
+
pageSize=1000, pageToken=token).execute()
|
|
422
|
+
for o in resp.get('oneTimeProductOffers', []):
|
|
423
|
+
out[o['offerId']] = o
|
|
424
|
+
token = resp.get('nextPageToken')
|
|
425
|
+
if not token:
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _time_of(value):
|
|
430
|
+
try:
|
|
431
|
+
return parse_time(value) if value else None
|
|
432
|
+
except (TypeError, ValueError):
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _pct(value):
|
|
437
|
+
"""A percentage as 50, not 50.0; 12.5 stays 12.5."""
|
|
438
|
+
value = round(float(value), 2)
|
|
439
|
+
return int(value) if value == int(value) else value
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _discount_of(current):
|
|
443
|
+
"""The relative discount the remote offer uses, as whole percent, or the
|
|
444
|
+
set of them when regions differ; None when any region is not relative."""
|
|
445
|
+
values = set()
|
|
446
|
+
for c in current.get('regionalPricingAndAvailabilityConfigs', []) or []:
|
|
447
|
+
if c.get('availability', 'AVAILABLE') != 'AVAILABLE':
|
|
448
|
+
continue
|
|
449
|
+
d = c.get('relativeDiscount')
|
|
450
|
+
values.add(None if d is None else _pct(float(d) * 100))
|
|
451
|
+
if len(values) == 1:
|
|
452
|
+
return values.pop()
|
|
453
|
+
return None if None in values or not values else sorted(values)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _offer_diff(current, body, offer, priced=()):
|
|
457
|
+
"""What differs between Play's offer and the file's: {field: {from, to}}.
|
|
458
|
+
|
|
459
|
+
A region missing from the offer counts only when the file prices it
|
|
460
|
+
(`priced`). One the purchase option has from Play alone may be one Play
|
|
461
|
+
will not bill at the file's regions version (see _send_offer), so it
|
|
462
|
+
would never stop showing; it is still sent with any other change."""
|
|
463
|
+
diff = {}
|
|
464
|
+
want_pct = _pct(offer['discount_pct'])
|
|
465
|
+
have_pct = _discount_of(current)
|
|
466
|
+
have = {c['regionCode']: c for c in current.get('regionalPricingAndAvailabilityConfigs', []) or []}
|
|
467
|
+
missing, wrong = [], []
|
|
468
|
+
for c in body['regionalPricingAndAvailabilityConfigs']:
|
|
469
|
+
h = have.get(c['regionCode'])
|
|
470
|
+
if h is None:
|
|
471
|
+
if c['regionCode'] in priced:
|
|
472
|
+
missing.append(c['regionCode'])
|
|
473
|
+
elif (h.get('availability') != 'AVAILABLE' or h.get('relativeDiscount') is None
|
|
474
|
+
or abs(float(h['relativeDiscount']) - c['relativeDiscount']) > 1e-6):
|
|
475
|
+
wrong.append(c['regionCode'])
|
|
476
|
+
if have_pct != want_pct or wrong:
|
|
477
|
+
diff['discount_pct'] = {'from': have_pct, 'to': want_pct}
|
|
478
|
+
if have_pct == want_pct:
|
|
479
|
+
diff['discount_pct']['regions'] = wrong
|
|
480
|
+
if missing:
|
|
481
|
+
diff['regions'] = {'added': missing}
|
|
482
|
+
window = current.get('discountedOffer') or {}
|
|
483
|
+
for key, field in (('start', 'startTime'), ('end', 'endTime')):
|
|
484
|
+
if key not in offer:
|
|
485
|
+
continue # start left to Play when the file does not set it
|
|
486
|
+
want = parse_time(offer[key])
|
|
487
|
+
if _time_of(window.get(field)) != want:
|
|
488
|
+
diff[key] = {'from': window.get(field), 'to': rfc3339(want)}
|
|
489
|
+
have_tags = sorted(t.get('tag') for t in current.get('offerTags', []) or [])
|
|
490
|
+
want_tags = sorted(offer.get('tags', []))
|
|
491
|
+
if have_tags != want_tags:
|
|
492
|
+
diff['tags'] = {'from': have_tags, 'to': want_tags}
|
|
493
|
+
return diff
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _plan_offers(svc, package, item, body, current, now):
|
|
497
|
+
offers = item.get('offer', [])
|
|
498
|
+
if not offers:
|
|
499
|
+
return []
|
|
500
|
+
changes = []
|
|
501
|
+
remote_by_option = {}
|
|
502
|
+
for offer in offers:
|
|
503
|
+
option_id = offer.get('purchase_option') or _offer_option(item, body, current)
|
|
504
|
+
regions = _offer_regions(option_id, body, current)
|
|
505
|
+
if not regions:
|
|
506
|
+
raise UsageError(f'offer {item["id"]}/{offer["id"]}: purchase option {option_id} is not available '
|
|
507
|
+
f'in any region on Play (does it exist?), so the offer could not be sold anywhere',
|
|
508
|
+
code='NO_OFFER_REGIONS')
|
|
509
|
+
if option_id not in remote_by_option:
|
|
510
|
+
known = any(o.get('purchaseOptionId') == option_id
|
|
511
|
+
for o in (current or {}).get('purchaseOptions', []) or [])
|
|
512
|
+
remote_by_option[option_id] = _remote_offers(svc, package, item['id'], option_id) if known else {}
|
|
513
|
+
have = remote_by_option[option_id].get(offer['id'])
|
|
514
|
+
wanted = offer_body(package, item['id'], option_id, offer, regions)
|
|
515
|
+
base = {'kind': 'offer', 'id': f'{item["id"]}/{offer["id"]}', 'product': item['id'],
|
|
516
|
+
'option': option_id, 'offer': offer['id'], 'discount_pct': offer['discount_pct'],
|
|
517
|
+
'body': wanted}
|
|
518
|
+
if have is None:
|
|
519
|
+
change = dict(base, action='create', state=None, regions=len(regions))
|
|
520
|
+
else:
|
|
521
|
+
desired = body['purchaseOptions'][0]
|
|
522
|
+
priced = ({c['regionCode'] for c in desired['regionalPricingAndAvailabilityConfigs']}
|
|
523
|
+
if desired['purchaseOptionId'] == option_id else set())
|
|
524
|
+
diff = _offer_diff(have, wanted, offer, priced)
|
|
525
|
+
state = have.get('state')
|
|
526
|
+
if diff:
|
|
527
|
+
change = dict(base, action='update', changes=diff, state=state)
|
|
528
|
+
elif state != 'ACTIVE':
|
|
529
|
+
change = dict(base, action='activate', changes={}, state=state)
|
|
530
|
+
else:
|
|
531
|
+
continue
|
|
532
|
+
if change['action'] != 'activate' and parse_time(offer['end']) <= now:
|
|
533
|
+
raise UsageError(f'offer {change["id"]}: end {offer["end"]} has already passed; '
|
|
534
|
+
f'set a future end or remove the offer from the file',
|
|
535
|
+
code='OFFER_ENDED')
|
|
536
|
+
changes.append(change)
|
|
537
|
+
return changes
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
_UNBILLABLE = re.compile(r'Region code (\w+) is not billable')
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _send_offer(offers, path, request, c):
|
|
544
|
+
"""Sends one offer, leaving out a region Play says is not billable at the
|
|
545
|
+
file's regions version (a product can sell in a region added to Play after
|
|
546
|
+
that version, which an offer pinned to it cannot name). The regions left
|
|
547
|
+
out are recorded on the change as `skipped_regions`."""
|
|
548
|
+
from googleapiclient.errors import HttpError
|
|
549
|
+
configs = request['oneTimeProductOffer'].setdefault('regionalPricingAndAvailabilityConfigs', [])
|
|
550
|
+
for _ in range(len(configs) + 1):
|
|
551
|
+
try:
|
|
552
|
+
return auth.retry(lambda: offers.batchUpdate(body={'requests': [request]}, **path),
|
|
553
|
+
f'offer {c["id"]}')
|
|
554
|
+
except HttpError as e:
|
|
555
|
+
m = _UNBILLABLE.search(auth.api_message(e) or '')
|
|
556
|
+
if auth.status_of(e) != 400 or not m:
|
|
557
|
+
raise
|
|
558
|
+
region = m.group(1)
|
|
559
|
+
kept = [r for r in configs if r.get('regionCode') != region]
|
|
560
|
+
if len(kept) == len(configs):
|
|
561
|
+
raise
|
|
562
|
+
configs[:] = kept
|
|
563
|
+
c.setdefault('skipped_regions', []).append(region)
|
|
564
|
+
raise RuntimeError(f'offer {c["id"]}: no billable region left')
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _apply_offer(svc, package, version, c):
|
|
568
|
+
offers = svc.monetization().onetimeproducts().purchaseOptions().offers()
|
|
569
|
+
path = dict(packageName=package, productId=c['product'], purchaseOptionId=c['option'])
|
|
570
|
+
if c['action'] in ('create', 'update'):
|
|
571
|
+
request = {'oneTimeProductOffer': c['body'], 'regionsVersion': {'version': version},
|
|
572
|
+
'updateMask': OFFER_MASK, 'allowMissing': True}
|
|
573
|
+
try:
|
|
574
|
+
_send_offer(offers, path, request, c)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
from googleapiclient.errors import HttpError
|
|
577
|
+
if (c['action'] == 'update' and c.get('state') == 'ACTIVE' and isinstance(e, HttpError)
|
|
578
|
+
and auth.status_of(e) in (400, 409, 412)):
|
|
579
|
+
raise Conflict(
|
|
580
|
+
f'Play refused to change the active offer {c["id"]}: {auth.api_message(e)}',
|
|
581
|
+
hint='Play may not allow editing a live offer. Deactivate it in Play Console and '
|
|
582
|
+
'give the new terms a new offer id, or put the file back to the live terms.',
|
|
583
|
+
code='OFFER_ACTIVE') from None
|
|
584
|
+
raise
|
|
585
|
+
if c.get('state') != 'ACTIVE':
|
|
586
|
+
auth.retry(lambda: offers.batchUpdateStates(body={'requests': [{'activateOneTimeProductOfferRequest': {
|
|
587
|
+
'packageName': package, 'productId': c['product'], 'purchaseOptionId': c['option'],
|
|
588
|
+
'offerId': c['offer']}}]}, **path), f'activate {c["id"]}')
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
# ------------------------------------------------------------------ plan / apply
|
|
592
|
+
|
|
593
|
+
def plan(svc, package, data, now=None):
|
|
594
|
+
pricing = data['pricing']
|
|
595
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
596
|
+
subs = _subscriptions(svc, package)
|
|
597
|
+
changes, offer_changes = [], []
|
|
598
|
+
for item in data.get('product', []):
|
|
599
|
+
body = product_body(package, pricing, item)
|
|
600
|
+
current = _get_product(svc, package, item['id'])
|
|
601
|
+
offer_changes += _plan_offers(svc, package, item, body, current, now)
|
|
602
|
+
if current is None:
|
|
603
|
+
changes.append({'kind': 'product', 'id': item['id'], 'action': 'create', 'body': body})
|
|
604
|
+
continue
|
|
605
|
+
option = next((o for o in current.get('purchaseOptions', [])
|
|
606
|
+
if o.get('purchaseOptionId') == body['purchaseOptions'][0]['purchaseOptionId']), {})
|
|
607
|
+
prices = _price_diff(option.get('regionalPricingAndAvailabilityConfigs'),
|
|
608
|
+
body['purchaseOptions'][0]['regionalPricingAndAvailabilityConfigs'], 'price')
|
|
609
|
+
listing = [l for l in body['listings'] if l not in current.get('listings', [])]
|
|
610
|
+
if prices or listing or option.get('state') != 'ACTIVE':
|
|
611
|
+
changes.append({'kind': 'product', 'id': item['id'], 'action': 'update', 'prices': prices,
|
|
612
|
+
'listings': [l['languageCode'] for l in listing],
|
|
613
|
+
'state': option.get('state'), 'body': body})
|
|
614
|
+
for item in data.get('subscription', []):
|
|
615
|
+
body = subscription_body(pricing, item)
|
|
616
|
+
current = subs.get(item['id'])
|
|
617
|
+
offer_changes += _plan_trial(svc, package, item, body, exists=current is not None)
|
|
618
|
+
if current is None:
|
|
619
|
+
changes.append({'kind': 'subscription', 'id': item['id'], 'action': 'create', 'body': body})
|
|
620
|
+
continue
|
|
621
|
+
plan_id = body['basePlans'][0]['basePlanId']
|
|
622
|
+
base = next((b for b in current.get('basePlans', []) if b.get('basePlanId') == plan_id), {})
|
|
623
|
+
prices = _price_diff(base.get('regionalConfigs'), body['basePlans'][0]['regionalConfigs'], 'price')
|
|
624
|
+
listing = [l for l in body['listings'] if l not in current.get('listings', [])]
|
|
625
|
+
if prices or listing or base.get('state') != 'ACTIVE':
|
|
626
|
+
changes.append({'kind': 'subscription', 'id': item['id'], 'action': 'update', 'prices': prices,
|
|
627
|
+
'listings': [l['languageCode'] for l in listing],
|
|
628
|
+
'state': base.get('state'), 'body': body})
|
|
629
|
+
# Offers last: apply needs their product and purchase option in place first.
|
|
630
|
+
return changes + offer_changes
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def apply(svc, package, data, changes):
|
|
634
|
+
"""Create or update, then activate: Play creates purchase options and base
|
|
635
|
+
plans in DRAFT whatever the body says, and a draft looks present but cannot
|
|
636
|
+
be bought."""
|
|
637
|
+
version = data.get('regions_version', DEFAULT_REGIONS_VERSION)
|
|
638
|
+
mono = svc.monetization()
|
|
639
|
+
for c in changes:
|
|
640
|
+
body = c['body']
|
|
641
|
+
if c['kind'] == 'offer':
|
|
642
|
+
_apply_offer(svc, package, version, c)
|
|
643
|
+
elif c['kind'] == 'trial':
|
|
644
|
+
_apply_trial(svc, package, version, c)
|
|
645
|
+
elif c['kind'] == 'product':
|
|
646
|
+
auth.retry(lambda: mono.onetimeproducts().patch(
|
|
647
|
+
packageName=package, productId=c['id'], allowMissing=True,
|
|
648
|
+
updateMask='listings,purchaseOptions', regionsVersion_version=version, body=body), c['id'])
|
|
649
|
+
option = body['purchaseOptions'][0]['purchaseOptionId']
|
|
650
|
+
auth.retry(lambda: mono.onetimeproducts().purchaseOptions().batchUpdateStates(
|
|
651
|
+
packageName=package, productId=c['id'], body={'requests': [{'activatePurchaseOptionRequest': {
|
|
652
|
+
'packageName': package, 'productId': c['id'], 'purchaseOptionId': option}}]}),
|
|
653
|
+
f'activate {c["id"]}')
|
|
654
|
+
else:
|
|
655
|
+
if c['action'] == 'create':
|
|
656
|
+
auth.retry(lambda: mono.subscriptions().create(
|
|
657
|
+
packageName=package, productId=c['id'], regionsVersion_version=version, body=body), c['id'])
|
|
658
|
+
else:
|
|
659
|
+
auth.retry(lambda: mono.subscriptions().patch(
|
|
660
|
+
packageName=package, productId=c['id'], updateMask='listings,basePlans',
|
|
661
|
+
regionsVersion_version=version, body=body), c['id'])
|
|
662
|
+
plan_id = body['basePlans'][0]['basePlanId']
|
|
663
|
+
auth.retry(lambda: mono.subscriptions().basePlans().activate(
|
|
664
|
+
packageName=package, productId=c['id'], basePlanId=plan_id,
|
|
665
|
+
body={'packageName': package, 'productId': c['id'], 'basePlanId': plan_id}),
|
|
666
|
+
f'activate {c["id"]}/{plan_id}')
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def convert(svc, package, amount, currency='USD'):
|
|
670
|
+
units = int(amount)
|
|
671
|
+
body = {'price': {'currencyCode': currency, 'units': str(units),
|
|
672
|
+
'nanos': int(round((amount - units) * 1e9))}}
|
|
673
|
+
resp = auth.retry(lambda: svc.monetization().convertRegionPrices(packageName=package, body=body),
|
|
674
|
+
'convert prices')
|
|
675
|
+
return {region: {'currency': v['price']['currencyCode'], 'amount': amount_of(v['price'])}
|
|
676
|
+
for region, v in resp.get('convertedRegionPrices', {}).items()}
|