|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+#!/usr/bin/env python3
|
|
|
2
|
+"""Sync App Store Connect subscriptions from Paywall.storekit."""
|
|
|
3
|
+
|
|
|
4
|
+from __future__ import annotations
|
|
|
5
|
+
|
|
|
6
|
+import json
|
|
|
7
|
+import sys
|
|
|
8
|
+import time
|
|
|
9
|
+from pathlib import Path
|
|
|
10
|
+
|
|
|
11
|
+import jwt
|
|
|
12
|
+import requests
|
|
|
13
|
+
|
|
|
14
|
+API_BASE = "https://api.appstoreconnect.apple.com/v1"
|
|
|
15
|
+ROOT = Path(__file__).resolve().parents[1]
|
|
|
16
|
+REPO_ROOT = ROOT.parent
|
|
|
17
|
+ENV_PATH = ROOT / ".env"
|
|
|
18
|
+STOREKIT_PATH = REPO_ROOT / "App for Indeed" / "Paywall.storekit"
|
|
|
19
|
+MANIFEST_PATH = ROOT / "subscriptions.manifest.json"
|
|
|
20
|
+BASE_TERRITORY = "USA"
|
|
|
21
|
+LOCALE = "en-US"
|
|
|
22
|
+
|
|
|
23
|
+
|
|
|
24
|
+def load_env(path: Path) -> dict[str, str]:
|
|
|
25
|
+ values: dict[str, str] = {}
|
|
|
26
|
+ for line in path.read_text(encoding="utf-8").splitlines():
|
|
|
27
|
+ line = line.strip()
|
|
|
28
|
+ if not line or line.startswith("#") or "=" not in line:
|
|
|
29
|
+ continue
|
|
|
30
|
+ key, value = line.split("=", 1)
|
|
|
31
|
+ values[key.strip()] = value.strip()
|
|
|
32
|
+ return values
|
|
|
33
|
+
|
|
|
34
|
+
|
|
|
35
|
+def storekit_locale_to_asc(locale: str) -> str:
|
|
|
36
|
+ return locale.replace("_", "-")
|
|
|
37
|
+
|
|
|
38
|
+
|
|
|
39
|
+def period_from_iso8601(value: str) -> str:
|
|
|
40
|
+ mapping = {
|
|
|
41
|
+ "P1W": "ONE_WEEK",
|
|
|
42
|
+ "P1M": "ONE_MONTH",
|
|
|
43
|
+ "P1Y": "ONE_YEAR",
|
|
|
44
|
+ }
|
|
|
45
|
+ if value not in mapping:
|
|
|
46
|
+ raise ValueError(f"Unsupported subscription period {value!r}")
|
|
|
47
|
+ return mapping[value]
|
|
|
48
|
+
|
|
|
49
|
+
|
|
|
50
|
+class ASCClient:
|
|
|
51
|
+ def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
|
|
|
52
|
+ self.issuer_id = issuer_id
|
|
|
53
|
+ self.key_id = key_id
|
|
|
54
|
+ self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
|
|
|
55
|
+ self.session = requests.Session()
|
|
|
56
|
+ self._token: str | None = None
|
|
|
57
|
+ self._token_exp = 0.0
|
|
|
58
|
+
|
|
|
59
|
+ def _ensure_token(self) -> str:
|
|
|
60
|
+ now = time.time()
|
|
|
61
|
+ if not self._token or now >= self._token_exp - 60:
|
|
|
62
|
+ issued_at = int(now)
|
|
|
63
|
+ payload = {
|
|
|
64
|
+ "iss": self.issuer_id,
|
|
|
65
|
+ "iat": issued_at,
|
|
|
66
|
+ "exp": issued_at + 1200,
|
|
|
67
|
+ "aud": "appstoreconnect-v1",
|
|
|
68
|
+ }
|
|
|
69
|
+ self._token = jwt.encode(
|
|
|
70
|
+ payload,
|
|
|
71
|
+ self.private_key,
|
|
|
72
|
+ algorithm="ES256",
|
|
|
73
|
+ headers={"kid": self.key_id, "typ": "JWT"},
|
|
|
74
|
+ )
|
|
|
75
|
+ self._token_exp = issued_at + 1200
|
|
|
76
|
+ return self._token
|
|
|
77
|
+
|
|
|
78
|
+ def request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
|
79
|
+ headers = kwargs.pop("headers", {})
|
|
|
80
|
+ headers["Authorization"] = f"Bearer {self._ensure_token()}"
|
|
|
81
|
+ headers.setdefault("Content-Type", "application/json")
|
|
|
82
|
+ url = path if path.startswith("http") else f"{API_BASE}{path}"
|
|
|
83
|
+ return self.session.request(method, url, headers=headers, timeout=90, **kwargs)
|
|
|
84
|
+
|
|
|
85
|
+ def get_json(self, path: str, params: dict | None = None) -> dict:
|
|
|
86
|
+ response = self.request("GET", path, params=params)
|
|
|
87
|
+ if not response.ok:
|
|
|
88
|
+ raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
|
|
|
89
|
+ return response.json()
|
|
|
90
|
+
|
|
|
91
|
+ def post(self, resource_type: str, attributes: dict, relationships: dict) -> dict:
|
|
|
92
|
+ body = {
|
|
|
93
|
+ "data": {
|
|
|
94
|
+ "type": resource_type,
|
|
|
95
|
+ "attributes": attributes,
|
|
|
96
|
+ "relationships": relationships,
|
|
|
97
|
+ }
|
|
|
98
|
+ }
|
|
|
99
|
+ collection = resource_type
|
|
|
100
|
+ response = self.request("POST", f"/{collection}", json=body)
|
|
|
101
|
+ if not response.ok:
|
|
|
102
|
+ raise RuntimeError(
|
|
|
103
|
+ f"POST {resource_type} failed ({response.status_code}): {response.text}"
|
|
|
104
|
+ )
|
|
|
105
|
+ return response.json()["data"]
|
|
|
106
|
+
|
|
|
107
|
+
|
|
|
108
|
+def find_app(client: ASCClient, bundle_id: str) -> dict:
|
|
|
109
|
+ data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
|
|
|
110
|
+ apps = data.get("data", [])
|
|
|
111
|
+ if not apps:
|
|
|
112
|
+ raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
|
|
|
113
|
+ return apps[0]
|
|
|
114
|
+
|
|
|
115
|
+
|
|
|
116
|
+def load_storekit(path: Path) -> dict:
|
|
|
117
|
+ return json.loads(path.read_text(encoding="utf-8"))
|
|
|
118
|
+
|
|
|
119
|
+
|
|
|
120
|
+def list_subscription_groups(client: ASCClient, app_id: str) -> list[dict]:
|
|
|
121
|
+ data = client.get_json(
|
|
|
122
|
+ f"/apps/{app_id}/subscriptionGroups",
|
|
|
123
|
+ params={"include": "subscriptions", "limit": 50},
|
|
|
124
|
+ )
|
|
|
125
|
+ groups = data.get("data", [])
|
|
|
126
|
+ included = {item["id"]: item for item in data.get("included", []) if item["type"] == "subscriptions"}
|
|
|
127
|
+ for group in groups:
|
|
|
128
|
+ rel_ids = [
|
|
|
129
|
+ item["id"]
|
|
|
130
|
+ for item in group.get("relationships", {})
|
|
|
131
|
+ .get("subscriptions", {})
|
|
|
132
|
+ .get("data", [])
|
|
|
133
|
+ ]
|
|
|
134
|
+ group["_subscriptions"] = [included[i] for i in rel_ids if i in included]
|
|
|
135
|
+ return groups
|
|
|
136
|
+
|
|
|
137
|
+
|
|
|
138
|
+def find_or_create_group(client: ASCClient, app_id: str, reference_name: str) -> dict:
|
|
|
139
|
+ for group in list_subscription_groups(client, app_id):
|
|
|
140
|
+ if group.get("attributes", {}).get("referenceName") == reference_name:
|
|
|
141
|
+ return group
|
|
|
142
|
+ created = client.post(
|
|
|
143
|
+ "subscriptionGroups",
|
|
|
144
|
+ {"referenceName": reference_name},
|
|
|
145
|
+ {"app": {"data": {"type": "apps", "id": app_id}}},
|
|
|
146
|
+ )
|
|
|
147
|
+ created["_subscriptions"] = []
|
|
|
148
|
+ return created
|
|
|
149
|
+
|
|
|
150
|
+
|
|
|
151
|
+def ensure_group_localization(
|
|
|
152
|
+ client: ASCClient, group_id: str, group_name: str, app_display_name: str
|
|
|
153
|
+) -> None:
|
|
|
154
|
+ data = client.get_json(f"/subscriptionGroups/{group_id}/subscriptionGroupLocalizations")
|
|
|
155
|
+ for loc in data.get("data", []):
|
|
|
156
|
+ if loc.get("attributes", {}).get("locale") == LOCALE:
|
|
|
157
|
+ return
|
|
|
158
|
+ client.post(
|
|
|
159
|
+ "subscriptionGroupLocalizations",
|
|
|
160
|
+ {"name": group_name, "locale": LOCALE, "customAppName": app_display_name},
|
|
|
161
|
+ {"subscriptionGroup": {"data": {"type": "subscriptionGroups", "id": group_id}}},
|
|
|
162
|
+ )
|
|
|
163
|
+
|
|
|
164
|
+
|
|
|
165
|
+def find_subscription_by_product_id(subscriptions: list[dict], product_id: str) -> dict | None:
|
|
|
166
|
+ for sub in subscriptions:
|
|
|
167
|
+ if sub.get("attributes", {}).get("productId") == product_id:
|
|
|
168
|
+ return sub
|
|
|
169
|
+ return None
|
|
|
170
|
+
|
|
|
171
|
+
|
|
|
172
|
+def create_subscription(
|
|
|
173
|
+ client: ASCClient, group_id: str, spec: dict, group_number: int
|
|
|
174
|
+) -> dict:
|
|
|
175
|
+ loc = spec["localizations"][0]
|
|
|
176
|
+ return client.post(
|
|
|
177
|
+ "subscriptions",
|
|
|
178
|
+ {
|
|
|
179
|
+ "name": spec["referenceName"],
|
|
|
180
|
+ "productId": spec["productID"],
|
|
|
181
|
+ "subscriptionPeriod": period_from_iso8601(spec["recurringSubscriptionPeriod"]),
|
|
|
182
|
+ "groupLevel": group_number,
|
|
|
183
|
+ },
|
|
|
184
|
+ {"group": {"data": {"type": "subscriptionGroups", "id": group_id}}},
|
|
|
185
|
+ )
|
|
|
186
|
+
|
|
|
187
|
+
|
|
|
188
|
+def ensure_subscription_localization(client: ASCClient, sub_id: str, spec: dict) -> None:
|
|
|
189
|
+ loc = spec["localizations"][0]
|
|
|
190
|
+ asc_locale = storekit_locale_to_asc(loc["locale"])
|
|
|
191
|
+ data = client.get_json(f"/subscriptions/{sub_id}/subscriptionLocalizations")
|
|
|
192
|
+ for item in data.get("data", []):
|
|
|
193
|
+ if item.get("attributes", {}).get("locale") == asc_locale:
|
|
|
194
|
+ return
|
|
|
195
|
+ client.post(
|
|
|
196
|
+ "subscriptionLocalizations",
|
|
|
197
|
+ {
|
|
|
198
|
+ "name": loc["displayName"],
|
|
|
199
|
+ "description": loc["description"],
|
|
|
200
|
+ "locale": asc_locale,
|
|
|
201
|
+ },
|
|
|
202
|
+ {"subscription": {"data": {"type": "subscriptions", "id": sub_id}}},
|
|
|
203
|
+ )
|
|
|
204
|
+
|
|
|
205
|
+
|
|
|
206
|
+def ensure_availability(client: ASCClient, sub_id: str) -> None:
|
|
|
207
|
+ try:
|
|
|
208
|
+ client.get_json(f"/subscriptionAvailabilities/{sub_id}")
|
|
|
209
|
+ return
|
|
|
210
|
+ except RuntimeError:
|
|
|
211
|
+ pass
|
|
|
212
|
+ client.post(
|
|
|
213
|
+ "subscriptionAvailabilities",
|
|
|
214
|
+ {"availableInNewTerritories": True},
|
|
|
215
|
+ {
|
|
|
216
|
+ "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
|
|
|
217
|
+ "availableTerritories": {"data": [{"type": "territories", "id": BASE_TERRITORY}]},
|
|
|
218
|
+ },
|
|
|
219
|
+ )
|
|
|
220
|
+
|
|
|
221
|
+
|
|
|
222
|
+def price_point_for_amount(client: ASCClient, sub_id: str, amount: str) -> str:
|
|
|
223
|
+ path = f"/subscriptions/{sub_id}/pricePoints?filter[territory]={BASE_TERRITORY}&limit=200"
|
|
|
224
|
+ while path:
|
|
|
225
|
+ data = client.get_json(path)
|
|
|
226
|
+ for point in data.get("data", []):
|
|
|
227
|
+ if point.get("attributes", {}).get("customerPrice") == amount:
|
|
|
228
|
+ return point["id"]
|
|
|
229
|
+ next_url = data.get("links", {}).get("next")
|
|
|
230
|
+ if not next_url:
|
|
|
231
|
+ break
|
|
|
232
|
+ path = next_url.replace(API_BASE, "")
|
|
|
233
|
+ raise RuntimeError(f"No {BASE_TERRITORY} price point for ${amount} on subscription {sub_id}")
|
|
|
234
|
+
|
|
|
235
|
+
|
|
|
236
|
+def ensure_price(client: ASCClient, sub_id: str, amount: str) -> None:
|
|
|
237
|
+ prices = client.get_json(f"/subscriptions/{sub_id}/prices")
|
|
|
238
|
+ if prices.get("meta", {}).get("paging", {}).get("total", 0) > 0:
|
|
|
239
|
+ return
|
|
|
240
|
+ price_point_id = price_point_for_amount(client, sub_id, amount)
|
|
|
241
|
+ client.post(
|
|
|
242
|
+ "subscriptionPrices",
|
|
|
243
|
+ {},
|
|
|
244
|
+ {
|
|
|
245
|
+ "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
|
|
|
246
|
+ "subscriptionPricePoint": {
|
|
|
247
|
+ "data": {"type": "subscriptionPricePoints", "id": price_point_id}
|
|
|
248
|
+ },
|
|
|
249
|
+ },
|
|
|
250
|
+ )
|
|
|
251
|
+
|
|
|
252
|
+
|
|
|
253
|
+def has_intro_offer(client: ASCClient, sub_id: str) -> bool:
|
|
|
254
|
+ data = client.get_json(f"/subscriptions/{sub_id}/introductoryOffers")
|
|
|
255
|
+ return data.get("meta", {}).get("paging", {}).get("total", 0) > 0
|
|
|
256
|
+
|
|
|
257
|
+
|
|
|
258
|
+def ensure_free_trial(client: ASCClient, sub_id: str, days: int) -> None:
|
|
|
259
|
+ if has_intro_offer(client, sub_id):
|
|
|
260
|
+ return
|
|
|
261
|
+ duration = {3: "THREE_DAYS"}.get(days)
|
|
|
262
|
+ if duration is None:
|
|
|
263
|
+ raise ValueError(f"Unsupported free-trial length: {days} days")
|
|
|
264
|
+ client.post(
|
|
|
265
|
+ "subscriptionIntroductoryOffers",
|
|
|
266
|
+ {"offerMode": "FREE_TRIAL", "duration": duration, "numberOfPeriods": 1},
|
|
|
267
|
+ {
|
|
|
268
|
+ "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
|
|
|
269
|
+ "territory": {"data": {"type": "territories", "id": BASE_TERRITORY}},
|
|
|
270
|
+ },
|
|
|
271
|
+ )
|
|
|
272
|
+
|
|
|
273
|
+
|
|
|
274
|
+def intro_days_from_storekit(offer: dict | None) -> int | None:
|
|
|
275
|
+ if not offer:
|
|
|
276
|
+ return None
|
|
|
277
|
+ if offer.get("paymentMode") != "free":
|
|
|
278
|
+ return None
|
|
|
279
|
+ period = offer.get("subscriptionPeriod", "")
|
|
|
280
|
+ if period == "P3D":
|
|
|
281
|
+ return 3
|
|
|
282
|
+ return None
|
|
|
283
|
+
|
|
|
284
|
+
|
|
|
285
|
+def main() -> int:
|
|
|
286
|
+ if not ENV_PATH.exists():
|
|
|
287
|
+ print(f"Missing {ENV_PATH}", file=sys.stderr)
|
|
|
288
|
+ return 1
|
|
|
289
|
+ if not STOREKIT_PATH.exists():
|
|
|
290
|
+ print(f"Missing {STOREKIT_PATH}", file=sys.stderr)
|
|
|
291
|
+ return 1
|
|
|
292
|
+
|
|
|
293
|
+ env = load_env(ENV_PATH)
|
|
|
294
|
+ issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
|
|
|
295
|
+ key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
|
|
|
296
|
+ key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
|
|
|
297
|
+ bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
|
|
|
298
|
+ if not all([issuer, key_id, key_path, bundle_id]):
|
|
|
299
|
+ print("Fill App Store Connect credentials in app-connect/.env", file=sys.stderr)
|
|
|
300
|
+ return 1
|
|
|
301
|
+
|
|
|
302
|
+ storekit = load_storekit(STOREKIT_PATH)
|
|
|
303
|
+ groups = storekit.get("subscriptionGroups", [])
|
|
|
304
|
+ if len(groups) != 1:
|
|
|
305
|
+ print("Expected exactly one subscription group in Paywall.storekit", file=sys.stderr)
|
|
|
306
|
+ return 1
|
|
|
307
|
+
|
|
|
308
|
+ sk_group = groups[0]
|
|
|
309
|
+ client = ASCClient(issuer, key_id, key_path)
|
|
|
310
|
+ app = find_app(client, bundle_id)
|
|
|
311
|
+ app_id = app["id"]
|
|
|
312
|
+ print(f"App: {app['attributes'].get('name')} ({app_id})")
|
|
|
313
|
+
|
|
|
314
|
+ asc_group = find_or_create_group(client, app_id, sk_group["name"])
|
|
|
315
|
+ group_id = asc_group["id"]
|
|
|
316
|
+ print(f"Subscription group: {sk_group['name']} ({group_id})")
|
|
|
317
|
+ ensure_group_localization(client, group_id, sk_group["name"], "App for Indeed")
|
|
|
318
|
+
|
|
|
319
|
+ manifest: dict = {
|
|
|
320
|
+ "subscriptionGroupId": group_id,
|
|
|
321
|
+ "subscriptionGroupName": sk_group["name"],
|
|
|
322
|
+ "products": [],
|
|
|
323
|
+ }
|
|
|
324
|
+
|
|
|
325
|
+ existing = list_subscription_groups(client, app_id)
|
|
|
326
|
+ current_subs: list[dict] = []
|
|
|
327
|
+ for group in existing:
|
|
|
328
|
+ if group["id"] == group_id:
|
|
|
329
|
+ current_subs = group.get("_subscriptions", [])
|
|
|
330
|
+ break
|
|
|
331
|
+
|
|
|
332
|
+ for spec in sorted(sk_group["subscriptions"], key=lambda s: s["groupNumber"]):
|
|
|
333
|
+ product_id = spec["productID"]
|
|
|
334
|
+ sub = find_subscription_by_product_id(current_subs, product_id)
|
|
|
335
|
+ if sub is None:
|
|
|
336
|
+ sub = create_subscription(client, group_id, spec, spec["groupNumber"])
|
|
|
337
|
+ print(f"Created subscription {product_id} ({sub['id']})")
|
|
|
338
|
+ current_subs.append(sub)
|
|
|
339
|
+ else:
|
|
|
340
|
+ print(f"Found subscription {product_id} ({sub['id']})")
|
|
|
341
|
+
|
|
|
342
|
+ sub_id = sub["id"]
|
|
|
343
|
+ ensure_subscription_localization(client, sub_id, spec)
|
|
|
344
|
+ ensure_availability(client, sub_id)
|
|
|
345
|
+ ensure_price(client, sub_id, spec["displayPrice"])
|
|
|
346
|
+
|
|
|
347
|
+ trial_days = intro_days_from_storekit(spec.get("introductoryOffer"))
|
|
|
348
|
+ if trial_days:
|
|
|
349
|
+ ensure_free_trial(client, sub_id, trial_days)
|
|
|
350
|
+ print(f" Free trial: {trial_days} days")
|
|
|
351
|
+
|
|
|
352
|
+ state = sub.get("attributes", {}).get("state")
|
|
|
353
|
+ refreshed = client.get_json(f"/subscriptions/{sub_id}")
|
|
|
354
|
+ state = refreshed["data"]["attributes"].get("state", state)
|
|
|
355
|
+ manifest["products"].append(
|
|
|
356
|
+ {
|
|
|
357
|
+ "productId": product_id,
|
|
|
358
|
+ "subscriptionId": sub_id,
|
|
|
359
|
+ "referenceName": spec["referenceName"],
|
|
|
360
|
+ "displayPrice": spec["displayPrice"],
|
|
|
361
|
+ "period": spec["recurringSubscriptionPeriod"],
|
|
|
362
|
+ "state": state,
|
|
|
363
|
+ }
|
|
|
364
|
+ )
|
|
|
365
|
+ print(f" State: {state}")
|
|
|
366
|
+
|
|
|
367
|
+ MANIFEST_PATH.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
|
368
|
+ print(f"Wrote manifest: {MANIFEST_PATH}")
|
|
|
369
|
+ print("Done. Review subscriptions in App Store Connect and submit for review when ready.")
|
|
|
370
|
+ return 0
|
|
|
371
|
+
|
|
|
372
|
+
|
|
|
373
|
+if __name__ == "__main__":
|
|
|
374
|
+ raise SystemExit(main())
|