@daemux/store-automator 0.10.23 → 0.10.24
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/plugins/store-automator/.claude-plugin/plugin.json +1 -1
- package/templates/fastlane/iap_config.json.template +8 -0
- package/templates/scripts/asc_subscription_setup.py +258 -0
- package/templates/scripts/ci/ios/sync-iap.sh +1 -0
- package/templates/scripts/sync_iap_ios.py +142 -28
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "App Store & Google Play automation for Flutter apps",
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.24"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "store-automator",
|
|
13
13
|
"source": "./plugins/store-automator",
|
|
14
14
|
"description": "3 agents for app store publishing: reviewer, meta-creator, media-designer",
|
|
15
|
-
"version": "0.10.
|
|
15
|
+
"version": "0.10.24",
|
|
16
16
|
"keywords": [
|
|
17
17
|
"flutter",
|
|
18
18
|
"app-store",
|
package/package.json
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"prices": {
|
|
21
21
|
"USD": "9.99"
|
|
22
22
|
},
|
|
23
|
+
"availability": {
|
|
24
|
+
"available_in_new_territories": true
|
|
25
|
+
},
|
|
26
|
+
"review_screenshot": "fastlane/screenshots/review/subscription_review.png",
|
|
23
27
|
"introductory_offer": {
|
|
24
28
|
"type": "FREE",
|
|
25
29
|
"duration": "ONE_WEEK",
|
|
@@ -40,6 +44,10 @@
|
|
|
40
44
|
"prices": {
|
|
41
45
|
"USD": "69.99"
|
|
42
46
|
},
|
|
47
|
+
"availability": {
|
|
48
|
+
"available_in_new_territories": true
|
|
49
|
+
},
|
|
50
|
+
"review_screenshot": "fastlane/screenshots/review/subscription_review.png",
|
|
43
51
|
"introductory_offer": {
|
|
44
52
|
"type": "FREE",
|
|
45
53
|
"duration": "ONE_MONTH",
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
App Store Connect Subscription Setup API layer.
|
|
3
|
+
|
|
4
|
+
Functions for managing subscription availability (territories),
|
|
5
|
+
pricing (price points), and review screenshot uploads.
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import requests
|
|
11
|
+
except ImportError:
|
|
12
|
+
import subprocess
|
|
13
|
+
subprocess.check_call(
|
|
14
|
+
[sys.executable, "-m", "pip", "install", "--break-system-packages", "requests"],
|
|
15
|
+
stdout=subprocess.DEVNULL,
|
|
16
|
+
)
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from asc_iap_api import BASE_URL, TIMEOUT, print_api_errors
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Availability
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def get_subscription_availability(headers: dict, sub_id: str) -> dict | None:
|
|
27
|
+
"""Fetch current availability settings for a subscription."""
|
|
28
|
+
resp = requests.get(
|
|
29
|
+
f"{BASE_URL}/subscriptions/{sub_id}/subscriptionAvailability",
|
|
30
|
+
headers=headers,
|
|
31
|
+
timeout=TIMEOUT,
|
|
32
|
+
)
|
|
33
|
+
if not resp.ok:
|
|
34
|
+
if resp.status_code == 404:
|
|
35
|
+
return None
|
|
36
|
+
print_api_errors(resp, f"get availability for subscription {sub_id}")
|
|
37
|
+
return None
|
|
38
|
+
return resp.json().get("data")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_subscription_availability(
|
|
42
|
+
headers: dict,
|
|
43
|
+
sub_id: str,
|
|
44
|
+
territory_ids: list[str],
|
|
45
|
+
available_in_new: bool = True,
|
|
46
|
+
) -> dict | None:
|
|
47
|
+
"""Create availability with specified territories for a subscription."""
|
|
48
|
+
territory_data = [
|
|
49
|
+
{"type": "territories", "id": tid} for tid in territory_ids
|
|
50
|
+
]
|
|
51
|
+
resp = requests.post(
|
|
52
|
+
f"{BASE_URL}/subscriptionAvailabilities",
|
|
53
|
+
json={
|
|
54
|
+
"data": {
|
|
55
|
+
"type": "subscriptionAvailabilities",
|
|
56
|
+
"attributes": {
|
|
57
|
+
"availableInNewTerritories": available_in_new,
|
|
58
|
+
},
|
|
59
|
+
"relationships": {
|
|
60
|
+
"subscription": {
|
|
61
|
+
"data": {"type": "subscriptions", "id": sub_id},
|
|
62
|
+
},
|
|
63
|
+
"availableTerritories": {
|
|
64
|
+
"data": territory_data,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
headers=headers,
|
|
70
|
+
timeout=TIMEOUT,
|
|
71
|
+
)
|
|
72
|
+
if not resp.ok:
|
|
73
|
+
print_api_errors(resp, f"create availability for subscription {sub_id}")
|
|
74
|
+
return None
|
|
75
|
+
print(f" Set availability for subscription {sub_id} ({len(territory_ids)} territories)")
|
|
76
|
+
return resp.json().get("data")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Pricing
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def get_subscription_prices(headers: dict, sub_id: str) -> list:
|
|
84
|
+
"""List existing prices for a subscription."""
|
|
85
|
+
resp = requests.get(
|
|
86
|
+
f"{BASE_URL}/subscriptions/{sub_id}/prices",
|
|
87
|
+
params={"include": "subscriptionPricePoint"},
|
|
88
|
+
headers=headers,
|
|
89
|
+
timeout=TIMEOUT,
|
|
90
|
+
)
|
|
91
|
+
if not resp.ok:
|
|
92
|
+
print_api_errors(resp, f"get prices for subscription {sub_id}")
|
|
93
|
+
return []
|
|
94
|
+
return resp.json().get("data", [])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_price_points_for_territory(
|
|
98
|
+
headers: dict, sub_id: str, territory: str,
|
|
99
|
+
) -> list:
|
|
100
|
+
"""Get available price points for a subscription in a given territory."""
|
|
101
|
+
resp = requests.get(
|
|
102
|
+
f"{BASE_URL}/subscriptions/{sub_id}/pricePoints",
|
|
103
|
+
params={
|
|
104
|
+
"filter[territory]": territory,
|
|
105
|
+
"include": "territory",
|
|
106
|
+
},
|
|
107
|
+
headers=headers,
|
|
108
|
+
timeout=TIMEOUT,
|
|
109
|
+
)
|
|
110
|
+
if not resp.ok:
|
|
111
|
+
print_api_errors(resp, f"get price points for {territory}")
|
|
112
|
+
return []
|
|
113
|
+
return resp.json().get("data", [])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def find_price_point_by_amount(
|
|
117
|
+
price_points: list, amount_str: str,
|
|
118
|
+
) -> dict | None:
|
|
119
|
+
"""Find a price point matching the given customer price string."""
|
|
120
|
+
for pp in price_points:
|
|
121
|
+
customer_price = pp.get("attributes", {}).get("customerPrice", "")
|
|
122
|
+
if customer_price == amount_str:
|
|
123
|
+
return pp
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def create_subscription_price(
|
|
128
|
+
headers: dict,
|
|
129
|
+
sub_id: str,
|
|
130
|
+
price_point_id: str,
|
|
131
|
+
start_date: str | None = None,
|
|
132
|
+
) -> dict | None:
|
|
133
|
+
"""Create a price entry for a subscription using a price point ID."""
|
|
134
|
+
resp = requests.post(
|
|
135
|
+
f"{BASE_URL}/subscriptionPrices",
|
|
136
|
+
json={
|
|
137
|
+
"data": {
|
|
138
|
+
"type": "subscriptionPrices",
|
|
139
|
+
"attributes": {
|
|
140
|
+
"startDate": start_date,
|
|
141
|
+
"preserveCurrentPrice": False,
|
|
142
|
+
},
|
|
143
|
+
"relationships": {
|
|
144
|
+
"subscription": {
|
|
145
|
+
"data": {"type": "subscriptions", "id": sub_id},
|
|
146
|
+
},
|
|
147
|
+
"subscriptionPricePoint": {
|
|
148
|
+
"data": {
|
|
149
|
+
"type": "subscriptionPricePoints",
|
|
150
|
+
"id": price_point_id,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
headers=headers,
|
|
157
|
+
timeout=TIMEOUT,
|
|
158
|
+
)
|
|
159
|
+
if not resp.ok:
|
|
160
|
+
print_api_errors(resp, f"create price for subscription {sub_id}")
|
|
161
|
+
return None
|
|
162
|
+
print(f" Set price for subscription {sub_id} (price point: {price_point_id})")
|
|
163
|
+
return resp.json().get("data")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Review Screenshot
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def get_review_screenshot(headers: dict, sub_id: str) -> dict | None:
|
|
171
|
+
"""Fetch the current review screenshot for a subscription."""
|
|
172
|
+
resp = requests.get(
|
|
173
|
+
f"{BASE_URL}/subscriptions/{sub_id}/appStoreReviewScreenshot",
|
|
174
|
+
headers=headers,
|
|
175
|
+
timeout=TIMEOUT,
|
|
176
|
+
)
|
|
177
|
+
if not resp.ok:
|
|
178
|
+
if resp.status_code == 404:
|
|
179
|
+
return None
|
|
180
|
+
print_api_errors(resp, f"get review screenshot for subscription {sub_id}")
|
|
181
|
+
return None
|
|
182
|
+
return resp.json().get("data")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def reserve_review_screenshot(
|
|
186
|
+
headers: dict, sub_id: str, file_name: str, file_size: int,
|
|
187
|
+
) -> dict | None:
|
|
188
|
+
"""Reserve a review screenshot upload slot for a subscription."""
|
|
189
|
+
resp = requests.post(
|
|
190
|
+
f"{BASE_URL}/subscriptionAppStoreReviewScreenshots",
|
|
191
|
+
json={
|
|
192
|
+
"data": {
|
|
193
|
+
"type": "subscriptionAppStoreReviewScreenshots",
|
|
194
|
+
"attributes": {
|
|
195
|
+
"fileName": file_name,
|
|
196
|
+
"fileSize": file_size,
|
|
197
|
+
},
|
|
198
|
+
"relationships": {
|
|
199
|
+
"subscription": {
|
|
200
|
+
"data": {"type": "subscriptions", "id": sub_id},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
headers=headers,
|
|
206
|
+
timeout=TIMEOUT,
|
|
207
|
+
)
|
|
208
|
+
if not resp.ok:
|
|
209
|
+
print_api_errors(resp, f"reserve screenshot for subscription {sub_id}")
|
|
210
|
+
return None
|
|
211
|
+
return resp.json().get("data")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def upload_screenshot_chunks(
|
|
215
|
+
upload_operations: list, file_data: bytes,
|
|
216
|
+
) -> bool:
|
|
217
|
+
"""Upload binary chunks to pre-signed URLs (no Authorization header)."""
|
|
218
|
+
for op in upload_operations:
|
|
219
|
+
url = op["url"]
|
|
220
|
+
offset = op["offset"]
|
|
221
|
+
length = op["length"]
|
|
222
|
+
chunk = file_data[offset : offset + length]
|
|
223
|
+
op_headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])}
|
|
224
|
+
resp = requests.put(url, headers=op_headers, data=chunk, timeout=TIMEOUT)
|
|
225
|
+
if not resp.ok:
|
|
226
|
+
print(
|
|
227
|
+
f"ERROR (upload chunk at offset {offset}): "
|
|
228
|
+
f"HTTP {resp.status_code} - {resp.text[:200]}",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
return False
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def commit_review_screenshot(
|
|
236
|
+
headers: dict, screenshot_id: str, md5_checksum: str,
|
|
237
|
+
) -> dict | None:
|
|
238
|
+
"""Commit an uploaded review screenshot by confirming its checksum."""
|
|
239
|
+
resp = requests.patch(
|
|
240
|
+
f"{BASE_URL}/subscriptionAppStoreReviewScreenshots/{screenshot_id}",
|
|
241
|
+
json={
|
|
242
|
+
"data": {
|
|
243
|
+
"type": "subscriptionAppStoreReviewScreenshots",
|
|
244
|
+
"id": screenshot_id,
|
|
245
|
+
"attributes": {
|
|
246
|
+
"sourceFileChecksum": md5_checksum,
|
|
247
|
+
"uploaded": True,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
headers=headers,
|
|
252
|
+
timeout=TIMEOUT,
|
|
253
|
+
)
|
|
254
|
+
if not resp.ok:
|
|
255
|
+
print_api_errors(resp, f"commit screenshot {screenshot_id}")
|
|
256
|
+
return None
|
|
257
|
+
print(f" Committed review screenshot (ID: {screenshot_id})")
|
|
258
|
+
return resp.json().get("data")
|
|
@@ -44,6 +44,7 @@ export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
|
|
|
44
44
|
export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
|
|
45
45
|
export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
|
|
46
46
|
export BUNDLE_ID="$BUNDLE_ID"
|
|
47
|
+
export PROJECT_ROOT="$PROJECT_ROOT"
|
|
47
48
|
|
|
48
49
|
echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
|
|
49
50
|
|
|
@@ -10,10 +10,12 @@ Required env vars:
|
|
|
10
10
|
APP_STORE_CONNECT_ISSUER_ID - Issuer ID from App Store Connect
|
|
11
11
|
APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
|
|
12
12
|
BUNDLE_ID - App bundle identifier
|
|
13
|
+
PROJECT_ROOT - Absolute path to the project root
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
16
|
python3 sync_iap_ios.py <path/to/iap_config.json>
|
|
16
17
|
"""
|
|
18
|
+
import hashlib
|
|
17
19
|
import json
|
|
18
20
|
import os
|
|
19
21
|
import sys
|
|
@@ -29,6 +31,32 @@ from asc_iap_api import (
|
|
|
29
31
|
list_subscriptions_in_group,
|
|
30
32
|
update_localization,
|
|
31
33
|
)
|
|
34
|
+
from asc_subscription_setup import (
|
|
35
|
+
commit_review_screenshot,
|
|
36
|
+
create_subscription_availability,
|
|
37
|
+
create_subscription_price,
|
|
38
|
+
find_price_point_by_amount,
|
|
39
|
+
get_price_points_for_territory,
|
|
40
|
+
get_review_screenshot,
|
|
41
|
+
get_subscription_availability,
|
|
42
|
+
get_subscription_prices,
|
|
43
|
+
reserve_review_screenshot,
|
|
44
|
+
upload_screenshot_chunks,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
CURRENCY_TO_TERRITORY = {
|
|
48
|
+
"USD": "USA", "EUR": "FRA", "GBP": "GBR", "JPY": "JPN",
|
|
49
|
+
"AUD": "AUS", "CAD": "CAN", "CHF": "CHE", "CNY": "CHN",
|
|
50
|
+
"KRW": "KOR", "SEK": "SWE", "NOK": "NOR", "DKK": "DNK",
|
|
51
|
+
"INR": "IND", "BRL": "BRA", "MXN": "MEX", "RUB": "RUS",
|
|
52
|
+
"TRY": "TUR", "SAR": "SAU", "AED": "ARE", "HKD": "HKG",
|
|
53
|
+
"SGD": "SGP", "NZD": "NZL", "TWD": "TWN", "THB": "THA",
|
|
54
|
+
"MYR": "MYS", "PHP": "PHL", "IDR": "IDN", "ILS": "ISR",
|
|
55
|
+
"ZAR": "ZAF", "PLN": "POL", "CZK": "CZE", "HUF": "HUN",
|
|
56
|
+
"RON": "ROU", "BGN": "BGR", "HRK": "HRV", "COP": "COL",
|
|
57
|
+
"CLP": "CHL", "PEN": "PER", "EGP": "EGY", "NGN": "NGA",
|
|
58
|
+
"PKR": "PAK", "KZT": "KAZ", "QAR": "QAT", "KWD": "KWT",
|
|
59
|
+
}
|
|
32
60
|
|
|
33
61
|
|
|
34
62
|
def find_or_create_group(headers: dict, app_id: str, reference_name: str, existing_groups: list) -> str:
|
|
@@ -62,7 +90,10 @@ def set_subscription_localization(headers: dict, sub_id: str, locale: str, loc_d
|
|
|
62
90
|
create_localization(headers, sub_id, locale, loc_data)
|
|
63
91
|
|
|
64
92
|
|
|
65
|
-
def sync_subscription_group(
|
|
93
|
+
def sync_subscription_group(
|
|
94
|
+
headers: dict, app_id: str, group_config: dict,
|
|
95
|
+
existing_groups: list, project_root: str,
|
|
96
|
+
) -> dict:
|
|
66
97
|
"""Sync a single subscription group and its subscriptions. Returns sync result."""
|
|
67
98
|
ref_name = group_config.get("reference_name", group_config.get("group_name", ""))
|
|
68
99
|
if not ref_name:
|
|
@@ -77,43 +108,124 @@ def sync_subscription_group(headers: dict, app_id: str, group_config: dict, exis
|
|
|
77
108
|
|
|
78
109
|
for sub_config in group_config.get("subscriptions", []):
|
|
79
110
|
sub_id = find_or_create_subscription(headers, group_id, sub_config, existing_subs)
|
|
80
|
-
|
|
111
|
+
for locale, loc_data in sub_config.get("localizations", {}).items():
|
|
112
|
+
set_subscription_localization(headers, sub_id, locale, loc_data)
|
|
113
|
+
_sync_availability(headers, sub_id, sub_config)
|
|
114
|
+
_sync_pricing(headers, sub_id, sub_config)
|
|
115
|
+
_sync_review_screenshot(headers, sub_id, sub_config, project_root)
|
|
81
116
|
sub_results.append({"product_id": sub_config["product_id"], "id": sub_id})
|
|
82
117
|
|
|
83
118
|
return {"group": ref_name, "group_id": group_id, "subscriptions": sub_results}
|
|
84
119
|
|
|
85
120
|
|
|
86
|
-
def
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
def _sync_availability(headers: dict, sub_id: str, sub_config: dict) -> None:
|
|
122
|
+
"""Ensure subscription territory availability is configured."""
|
|
123
|
+
avail_config = sub_config.get("availability", {})
|
|
124
|
+
available_in_new = avail_config.get("available_in_new_territories", True)
|
|
125
|
+
territory_ids = avail_config.get("territories", [])
|
|
126
|
+
|
|
127
|
+
existing = get_subscription_availability(headers, sub_id)
|
|
128
|
+
if existing:
|
|
129
|
+
print(" Subscription availability already configured")
|
|
90
130
|
return
|
|
91
|
-
for locale, loc_data in localizations.items():
|
|
92
|
-
set_subscription_localization(headers, sub_id, locale, loc_data)
|
|
93
131
|
|
|
132
|
+
result = create_subscription_availability(
|
|
133
|
+
headers, sub_id, territory_ids, available_in_new=available_in_new,
|
|
134
|
+
)
|
|
135
|
+
if result:
|
|
136
|
+
print(" Subscription availability configured successfully")
|
|
137
|
+
else:
|
|
138
|
+
print(" WARNING: Failed to configure availability", file=sys.stderr)
|
|
94
139
|
|
|
95
|
-
def validate_env() -> tuple:
|
|
96
|
-
"""Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id)."""
|
|
97
|
-
key_id = os.environ.get("APP_STORE_CONNECT_KEY_IDENTIFIER", "")
|
|
98
|
-
issuer_id = os.environ.get("APP_STORE_CONNECT_ISSUER_ID", "")
|
|
99
|
-
private_key = os.environ.get("APP_STORE_CONNECT_PRIVATE_KEY", "")
|
|
100
|
-
bundle_id = os.environ.get("BUNDLE_ID", "")
|
|
101
|
-
|
|
102
|
-
missing = []
|
|
103
|
-
if not key_id:
|
|
104
|
-
missing.append("APP_STORE_CONNECT_KEY_IDENTIFIER")
|
|
105
|
-
if not issuer_id:
|
|
106
|
-
missing.append("APP_STORE_CONNECT_ISSUER_ID")
|
|
107
|
-
if not private_key:
|
|
108
|
-
missing.append("APP_STORE_CONNECT_PRIVATE_KEY")
|
|
109
|
-
if not bundle_id:
|
|
110
|
-
missing.append("BUNDLE_ID")
|
|
111
140
|
|
|
141
|
+
def _sync_pricing(headers: dict, sub_id: str, sub_config: dict) -> None:
|
|
142
|
+
"""Set subscription prices per territory from config."""
|
|
143
|
+
prices = sub_config.get("prices", {})
|
|
144
|
+
if not prices:
|
|
145
|
+
print(" WARNING: No prices configured, skipping pricing", file=sys.stderr)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
existing_prices = get_subscription_prices(headers, sub_id)
|
|
149
|
+
if existing_prices:
|
|
150
|
+
print(" Pricing already configured")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
for currency, amount in prices.items():
|
|
154
|
+
territory = CURRENCY_TO_TERRITORY.get(currency)
|
|
155
|
+
if not territory:
|
|
156
|
+
print(f" WARNING: Unknown currency '{currency}', skipping", file=sys.stderr)
|
|
157
|
+
continue
|
|
158
|
+
price_points = get_price_points_for_territory(headers, sub_id, territory)
|
|
159
|
+
point = find_price_point_by_amount(price_points, amount)
|
|
160
|
+
if not point:
|
|
161
|
+
print(f" WARNING: No price point matching {amount} for {territory}", file=sys.stderr)
|
|
162
|
+
continue
|
|
163
|
+
result = create_subscription_price(headers, sub_id, point["id"])
|
|
164
|
+
if result:
|
|
165
|
+
print(f" Set price {amount} for territory {territory}")
|
|
166
|
+
else:
|
|
167
|
+
print(f" WARNING: Failed to set price for {territory}", file=sys.stderr)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _sync_review_screenshot(
|
|
171
|
+
headers: dict, sub_id: str, sub_config: dict, project_root: str,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Upload a review screenshot for the subscription if configured."""
|
|
174
|
+
screenshot_path = sub_config.get("review_screenshot")
|
|
175
|
+
if not screenshot_path:
|
|
176
|
+
print(" WARNING: No review_screenshot configured, skipping", file=sys.stderr)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
full_path = os.path.join(project_root, screenshot_path)
|
|
180
|
+
if not os.path.isfile(full_path):
|
|
181
|
+
print(f" WARNING: Screenshot not found: {full_path}", file=sys.stderr)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
existing = get_review_screenshot(headers, sub_id)
|
|
185
|
+
if existing:
|
|
186
|
+
print(" Review screenshot already uploaded")
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
with open(full_path, "rb") as f:
|
|
190
|
+
file_data = f.read()
|
|
191
|
+
|
|
192
|
+
file_name = os.path.basename(full_path)
|
|
193
|
+
md5_checksum = hashlib.md5(file_data).hexdigest()
|
|
194
|
+
|
|
195
|
+
reservation = reserve_review_screenshot(headers, sub_id, file_name, len(file_data))
|
|
196
|
+
if not reservation:
|
|
197
|
+
print(" WARNING: Failed to reserve screenshot upload", file=sys.stderr)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
screenshot_id = reservation["id"]
|
|
201
|
+
upload_ops = reservation["attributes"].get("uploadOperations", [])
|
|
202
|
+
|
|
203
|
+
success = upload_screenshot_chunks(upload_ops, file_data)
|
|
204
|
+
if not success:
|
|
205
|
+
print(" WARNING: Screenshot chunk upload failed, skipping commit", file=sys.stderr)
|
|
206
|
+
return
|
|
207
|
+
result = commit_review_screenshot(headers, screenshot_id, md5_checksum)
|
|
208
|
+
if result:
|
|
209
|
+
print(f" Review screenshot uploaded: {file_name}")
|
|
210
|
+
else:
|
|
211
|
+
print(" WARNING: Failed to commit screenshot upload", file=sys.stderr)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_env() -> tuple:
|
|
215
|
+
"""Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id, project_root)."""
|
|
216
|
+
required_vars = [
|
|
217
|
+
"APP_STORE_CONNECT_KEY_IDENTIFIER",
|
|
218
|
+
"APP_STORE_CONNECT_ISSUER_ID",
|
|
219
|
+
"APP_STORE_CONNECT_PRIVATE_KEY",
|
|
220
|
+
"BUNDLE_ID",
|
|
221
|
+
"PROJECT_ROOT",
|
|
222
|
+
]
|
|
223
|
+
values = {var: os.environ.get(var, "") for var in required_vars}
|
|
224
|
+
missing = [var for var, val in values.items() if not val]
|
|
112
225
|
if missing:
|
|
113
226
|
print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
|
|
114
227
|
sys.exit(1)
|
|
115
|
-
|
|
116
|
-
return key_id, issuer_id, private_key, bundle_id
|
|
228
|
+
return tuple(values.values())
|
|
117
229
|
|
|
118
230
|
|
|
119
231
|
def load_iap_config(config_path: str) -> dict:
|
|
@@ -137,7 +249,7 @@ def main() -> None:
|
|
|
137
249
|
sys.exit(1)
|
|
138
250
|
|
|
139
251
|
config_path = sys.argv[1]
|
|
140
|
-
key_id, issuer_id, private_key, bundle_id = validate_env()
|
|
252
|
+
key_id, issuer_id, private_key, bundle_id, project_root = validate_env()
|
|
141
253
|
|
|
142
254
|
token = get_jwt_token(key_id, issuer_id, private_key)
|
|
143
255
|
headers = {
|
|
@@ -153,7 +265,9 @@ def main() -> None:
|
|
|
153
265
|
results = []
|
|
154
266
|
|
|
155
267
|
for group_config in config.get("subscription_groups", []):
|
|
156
|
-
result = sync_subscription_group(
|
|
268
|
+
result = sync_subscription_group(
|
|
269
|
+
headers, app_id, group_config, existing_groups, project_root,
|
|
270
|
+
)
|
|
157
271
|
results.append(result)
|
|
158
272
|
|
|
159
273
|
print(f"\n{json.dumps({'synced_groups': results}, indent=2)}")
|