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/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()}