@daemux/store-automator 0.10.23 → 0.10.25

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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "App Store & Google Play automation for Flutter apps",
8
- "version": "0.10.23"
8
+ "version": "0.10.25"
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.23",
15
+ "version": "0.10.25",
16
16
  "keywords": [
17
17
  "flutter",
18
18
  "app-store",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemux/store-automator",
3
- "version": "0.10.23",
3
+ "version": "0.10.25",
4
4
  "description": "Full App Store & Google Play automation for Flutter apps with Claude Code agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "store-automator",
3
- "version": "0.10.23",
3
+ "version": "0.10.25",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -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,268 @@
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 (numeric comparison).
120
+
121
+ Apple's API may return prices with trailing zeros (e.g. "9.990" instead
122
+ of "9.99"), so we compare as floats with a small tolerance rather than
123
+ doing an exact string match.
124
+ """
125
+ for pp in price_points:
126
+ customer_price = pp.get("attributes", {}).get("customerPrice", "")
127
+ try:
128
+ api_price = float(customer_price)
129
+ target_price = float(amount_str)
130
+ if abs(api_price - target_price) < 0.01:
131
+ return pp
132
+ except (ValueError, TypeError):
133
+ continue
134
+ return None
135
+
136
+
137
+ def create_subscription_price(
138
+ headers: dict,
139
+ sub_id: str,
140
+ price_point_id: str,
141
+ start_date: str | None = None,
142
+ ) -> dict | None:
143
+ """Create a price entry for a subscription using a price point ID."""
144
+ resp = requests.post(
145
+ f"{BASE_URL}/subscriptionPrices",
146
+ json={
147
+ "data": {
148
+ "type": "subscriptionPrices",
149
+ "attributes": {
150
+ "startDate": start_date,
151
+ "preserveCurrentPrice": False,
152
+ },
153
+ "relationships": {
154
+ "subscription": {
155
+ "data": {"type": "subscriptions", "id": sub_id},
156
+ },
157
+ "subscriptionPricePoint": {
158
+ "data": {
159
+ "type": "subscriptionPricePoints",
160
+ "id": price_point_id,
161
+ },
162
+ },
163
+ },
164
+ }
165
+ },
166
+ headers=headers,
167
+ timeout=TIMEOUT,
168
+ )
169
+ if not resp.ok:
170
+ print_api_errors(resp, f"create price for subscription {sub_id}")
171
+ return None
172
+ print(f" Set price for subscription {sub_id} (price point: {price_point_id})")
173
+ return resp.json().get("data")
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Review Screenshot
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def get_review_screenshot(headers: dict, sub_id: str) -> dict | None:
181
+ """Fetch the current review screenshot for a subscription."""
182
+ resp = requests.get(
183
+ f"{BASE_URL}/subscriptions/{sub_id}/appStoreReviewScreenshot",
184
+ headers=headers,
185
+ timeout=TIMEOUT,
186
+ )
187
+ if not resp.ok:
188
+ if resp.status_code == 404:
189
+ return None
190
+ print_api_errors(resp, f"get review screenshot for subscription {sub_id}")
191
+ return None
192
+ return resp.json().get("data")
193
+
194
+
195
+ def reserve_review_screenshot(
196
+ headers: dict, sub_id: str, file_name: str, file_size: int,
197
+ ) -> dict | None:
198
+ """Reserve a review screenshot upload slot for a subscription."""
199
+ resp = requests.post(
200
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots",
201
+ json={
202
+ "data": {
203
+ "type": "subscriptionAppStoreReviewScreenshots",
204
+ "attributes": {
205
+ "fileName": file_name,
206
+ "fileSize": file_size,
207
+ },
208
+ "relationships": {
209
+ "subscription": {
210
+ "data": {"type": "subscriptions", "id": sub_id},
211
+ },
212
+ },
213
+ }
214
+ },
215
+ headers=headers,
216
+ timeout=TIMEOUT,
217
+ )
218
+ if not resp.ok:
219
+ print_api_errors(resp, f"reserve screenshot for subscription {sub_id}")
220
+ return None
221
+ return resp.json().get("data")
222
+
223
+
224
+ def upload_screenshot_chunks(
225
+ upload_operations: list, file_data: bytes,
226
+ ) -> bool:
227
+ """Upload binary chunks to pre-signed URLs (no Authorization header)."""
228
+ for op in upload_operations:
229
+ url = op["url"]
230
+ offset = op["offset"]
231
+ length = op["length"]
232
+ chunk = file_data[offset : offset + length]
233
+ op_headers = {h["name"]: h["value"] for h in op.get("requestHeaders", [])}
234
+ resp = requests.put(url, headers=op_headers, data=chunk, timeout=TIMEOUT)
235
+ if not resp.ok:
236
+ print(
237
+ f"ERROR (upload chunk at offset {offset}): "
238
+ f"HTTP {resp.status_code} - {resp.text[:200]}",
239
+ file=sys.stderr,
240
+ )
241
+ return False
242
+ return True
243
+
244
+
245
+ def commit_review_screenshot(
246
+ headers: dict, screenshot_id: str, md5_checksum: str,
247
+ ) -> dict | None:
248
+ """Commit an uploaded review screenshot by confirming its checksum."""
249
+ resp = requests.patch(
250
+ f"{BASE_URL}/subscriptionAppStoreReviewScreenshots/{screenshot_id}",
251
+ json={
252
+ "data": {
253
+ "type": "subscriptionAppStoreReviewScreenshots",
254
+ "id": screenshot_id,
255
+ "attributes": {
256
+ "sourceFileChecksum": md5_checksum,
257
+ "uploaded": True,
258
+ },
259
+ }
260
+ },
261
+ headers=headers,
262
+ timeout=TIMEOUT,
263
+ )
264
+ if not resp.ok:
265
+ print_api_errors(resp, f"commit screenshot {screenshot_id}")
266
+ return None
267
+ print(f" Committed review screenshot (ID: {screenshot_id})")
268
+ 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(headers: dict, app_id: str, group_config: dict, existing_groups: list) -> dict:
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,132 @@ 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
- _sync_subscription_localizations(headers, sub_id, sub_config)
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 _sync_subscription_localizations(headers: dict, sub_id: str, sub_config: dict) -> None:
87
- """Sync localizations for a subscription from its config."""
88
- localizations = sub_config.get("localizations", {})
89
- if not localizations:
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
+ sample = [
162
+ pp.get("attributes", {}).get("customerPrice", "?")
163
+ for pp in price_points[:5]
164
+ ]
165
+ print(
166
+ f" WARNING: No price point matching {amount} for {territory}"
167
+ f" (API returned {len(price_points)} points, first prices: {sample})",
168
+ file=sys.stderr,
169
+ )
170
+ continue
171
+ result = create_subscription_price(headers, sub_id, point["id"])
172
+ if result:
173
+ print(f" Set price {amount} for territory {territory}")
174
+ else:
175
+ print(f" WARNING: Failed to set price for {territory}", file=sys.stderr)
176
+
177
+
178
+ def _sync_review_screenshot(
179
+ headers: dict, sub_id: str, sub_config: dict, project_root: str,
180
+ ) -> None:
181
+ """Upload a review screenshot for the subscription if configured."""
182
+ screenshot_path = sub_config.get("review_screenshot")
183
+ if not screenshot_path:
184
+ print(" WARNING: No review_screenshot configured, skipping", file=sys.stderr)
185
+ return
186
+
187
+ full_path = os.path.join(project_root, screenshot_path)
188
+ if not os.path.isfile(full_path):
189
+ print(f" WARNING: Screenshot not found: {full_path}", file=sys.stderr)
190
+ return
191
+
192
+ existing = get_review_screenshot(headers, sub_id)
193
+ if existing:
194
+ print(" Review screenshot already uploaded")
195
+ return
196
+
197
+ with open(full_path, "rb") as f:
198
+ file_data = f.read()
199
+
200
+ file_name = os.path.basename(full_path)
201
+ md5_checksum = hashlib.md5(file_data).hexdigest()
202
+
203
+ reservation = reserve_review_screenshot(headers, sub_id, file_name, len(file_data))
204
+ if not reservation:
205
+ print(" WARNING: Failed to reserve screenshot upload", file=sys.stderr)
206
+ return
207
+
208
+ screenshot_id = reservation["id"]
209
+ upload_ops = reservation["attributes"].get("uploadOperations", [])
210
+
211
+ success = upload_screenshot_chunks(upload_ops, file_data)
212
+ if not success:
213
+ print(" WARNING: Screenshot chunk upload failed, skipping commit", file=sys.stderr)
214
+ return
215
+ result = commit_review_screenshot(headers, screenshot_id, md5_checksum)
216
+ if result:
217
+ print(f" Review screenshot uploaded: {file_name}")
218
+ else:
219
+ print(" WARNING: Failed to commit screenshot upload", file=sys.stderr)
220
+
221
+
222
+ def validate_env() -> tuple:
223
+ """Validate required environment variables. Returns (key_id, issuer_id, private_key, bundle_id, project_root)."""
224
+ required_vars = [
225
+ "APP_STORE_CONNECT_KEY_IDENTIFIER",
226
+ "APP_STORE_CONNECT_ISSUER_ID",
227
+ "APP_STORE_CONNECT_PRIVATE_KEY",
228
+ "BUNDLE_ID",
229
+ "PROJECT_ROOT",
230
+ ]
231
+ values = {var: os.environ.get(var, "") for var in required_vars}
232
+ missing = [var for var, val in values.items() if not val]
112
233
  if missing:
113
234
  print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
114
235
  sys.exit(1)
115
-
116
- return key_id, issuer_id, private_key, bundle_id
236
+ return tuple(values.values())
117
237
 
118
238
 
119
239
  def load_iap_config(config_path: str) -> dict:
@@ -137,7 +257,7 @@ def main() -> None:
137
257
  sys.exit(1)
138
258
 
139
259
  config_path = sys.argv[1]
140
- key_id, issuer_id, private_key, bundle_id = validate_env()
260
+ key_id, issuer_id, private_key, bundle_id, project_root = validate_env()
141
261
 
142
262
  token = get_jwt_token(key_id, issuer_id, private_key)
143
263
  headers = {
@@ -153,7 +273,9 @@ def main() -> None:
153
273
  results = []
154
274
 
155
275
  for group_config in config.get("subscription_groups", []):
156
- result = sync_subscription_group(headers, app_id, group_config, existing_groups)
276
+ result = sync_subscription_group(
277
+ headers, app_id, group_config, existing_groups, project_root,
278
+ )
157
279
  results.append(result)
158
280
 
159
281
  print(f"\n{json.dumps({'synced_groups': results}, indent=2)}")