@daemux/store-automator 0.10.6 → 0.10.8

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.6"
8
+ "version": "0.10.8"
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.6",
15
+ "version": "0.10.8",
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.6",
3
+ "version": "0.10.8",
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.6",
3
+ "version": "0.10.8",
4
4
  "description": "App Store & Google Play automation agents for Flutter app publishing",
5
5
  "author": {
6
6
  "name": "Daemux"
@@ -113,11 +113,9 @@ platform :android do
113
113
  end
114
114
 
115
115
  lane :sync_google_iap do
116
- manage_google_iap(
117
- json_key: ENV["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"],
118
- package_name: ENV["PACKAGE_NAME"],
119
- config_path: "#{ROOT_DIR}/fastlane/iap_config.json"
120
- )
116
+ config_path = "#{ROOT_DIR}/fastlane/iap_config.json"
117
+ script = "#{ROOT_DIR}/scripts/sync_iap_android.py"
118
+ sh("python3", script, config_path) if File.exist?(config_path) && File.exist?(script)
121
119
  end
122
120
 
123
121
  lane :update_data_safety do
@@ -1,4 +1,2 @@
1
- # fastlane-plugin-iap is not yet published.
2
- # IAP sync is handled gracefully in CI workflows (non-blocking).
3
- # Uncomment when the plugin is available:
4
- # gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"
1
+ # IAP sync is handled by scripts/sync_iap_android.py (direct Google Play API).
2
+ # No fastlane plugins required for IAP management.
@@ -110,10 +110,8 @@ platform :ios do
110
110
  end
111
111
 
112
112
  lane :sync_iap do
113
- manage_iap(
114
- api_key: asc_api_key,
115
- app_id: ENV["BUNDLE_ID"],
116
- config_path: "#{ROOT_DIR}/fastlane/iap_config.json"
117
- )
113
+ config_path = "#{ROOT_DIR}/fastlane/iap_config.json"
114
+ script = "#{ROOT_DIR}/scripts/sync_iap_ios.py"
115
+ sh("python3", script, config_path) if File.exist?(config_path) && File.exist?(script)
118
116
  end
119
117
  end
@@ -1,4 +1,2 @@
1
- # fastlane-plugin-iap is not yet published.
2
- # IAP sync is handled gracefully in CI workflows (non-blocking).
3
- # Uncomment when the plugin is available:
4
- # gem "fastlane-plugin-iap", git: "https://github.com/daemux/fastlane-plugin-iap"
1
+ # IAP sync is handled by scripts/sync_iap_ios.py (direct App Store Connect API).
2
+ # No fastlane plugins required for IAP management.
@@ -0,0 +1,215 @@
1
+ """
2
+ App Store Connect IAP API layer.
3
+
4
+ Low-level functions for interacting with the App Store Connect REST API
5
+ for subscription groups and subscriptions.
6
+ """
7
+ import sys
8
+ import time
9
+
10
+ try:
11
+ import jwt
12
+ import requests
13
+ except ImportError:
14
+ import subprocess
15
+ subprocess.check_call(
16
+ [sys.executable, "-m", "pip", "install", "--break-system-packages", "PyJWT", "cryptography", "requests"],
17
+ stdout=subprocess.DEVNULL,
18
+ )
19
+ import jwt
20
+ import requests
21
+
22
+ BASE_URL = "https://api.appstoreconnect.apple.com/v1"
23
+ TIMEOUT = (10, 30)
24
+
25
+ # ISO 8601 duration to App Store Connect subscription period mapping
26
+ DURATION_MAP = {
27
+ "P1W": "ONE_WEEK",
28
+ "P1M": "ONE_MONTH",
29
+ "P2M": "TWO_MONTHS",
30
+ "P3M": "THREE_MONTHS",
31
+ "P6M": "SIX_MONTHS",
32
+ "P1Y": "ONE_YEAR",
33
+ "ONE_WEEK": "ONE_WEEK",
34
+ "ONE_MONTH": "ONE_MONTH",
35
+ "TWO_MONTHS": "TWO_MONTHS",
36
+ "THREE_MONTHS": "THREE_MONTHS",
37
+ "SIX_MONTHS": "SIX_MONTHS",
38
+ "ONE_YEAR": "ONE_YEAR",
39
+ }
40
+
41
+
42
+ def get_jwt_token(key_id: str, issuer_id: str, private_key: str) -> str:
43
+ """Generate a signed JWT for App Store Connect API authentication."""
44
+ payload = {
45
+ "iss": issuer_id,
46
+ "iat": int(time.time()),
47
+ "exp": int(time.time()) + 1200,
48
+ "aud": "appstoreconnect-v1",
49
+ }
50
+ return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
51
+
52
+
53
+ def get_app_id(headers: dict, bundle_id: str) -> str:
54
+ """Look up the App Store Connect app ID for the given bundle identifier."""
55
+ resp = requests.get(
56
+ f"{BASE_URL}/apps",
57
+ params={"filter[bundleId]": bundle_id},
58
+ headers=headers,
59
+ timeout=TIMEOUT,
60
+ )
61
+ resp.raise_for_status()
62
+ data = resp.json().get("data", [])
63
+ if not data:
64
+ print(f"ERROR: No app found for bundle ID '{bundle_id}'", file=sys.stderr)
65
+ sys.exit(1)
66
+ return data[0]["id"]
67
+
68
+
69
+ def list_subscription_groups(headers: dict, app_id: str) -> list:
70
+ """List all existing subscription groups for the app."""
71
+ resp = requests.get(
72
+ f"{BASE_URL}/apps/{app_id}/subscriptionGroups",
73
+ headers=headers,
74
+ timeout=TIMEOUT,
75
+ )
76
+ resp.raise_for_status()
77
+ return resp.json().get("data", [])
78
+
79
+
80
+ def create_subscription_group(headers: dict, app_id: str, reference_name: str) -> str:
81
+ """Create a subscription group and return its ID."""
82
+ resp = requests.post(
83
+ f"{BASE_URL}/subscriptionGroups",
84
+ json={
85
+ "data": {
86
+ "type": "subscriptionGroups",
87
+ "attributes": {"referenceName": reference_name},
88
+ "relationships": {
89
+ "app": {"data": {"type": "apps", "id": app_id}}
90
+ },
91
+ }
92
+ },
93
+ headers=headers,
94
+ timeout=TIMEOUT,
95
+ )
96
+ if not resp.ok:
97
+ print_api_errors(resp, f"create subscription group '{reference_name}'")
98
+ sys.exit(1)
99
+ group_id = resp.json()["data"]["id"]
100
+ print(f" Created subscription group '{reference_name}' (ID: {group_id})")
101
+ return group_id
102
+
103
+
104
+ def list_subscriptions_in_group(headers: dict, group_id: str) -> list:
105
+ """List all subscriptions within a subscription group."""
106
+ resp = requests.get(
107
+ f"{BASE_URL}/subscriptionGroups/{group_id}/subscriptions",
108
+ headers=headers,
109
+ timeout=TIMEOUT,
110
+ )
111
+ resp.raise_for_status()
112
+ return resp.json().get("data", [])
113
+
114
+
115
+ def create_subscription(headers: dict, group_id: str, sub_config: dict) -> str:
116
+ """Create a subscription within a group and return its ID."""
117
+ duration = DURATION_MAP.get(sub_config["duration"], sub_config["duration"])
118
+ resp = requests.post(
119
+ f"{BASE_URL}/subscriptions",
120
+ json={
121
+ "data": {
122
+ "type": "subscriptions",
123
+ "attributes": {
124
+ "productId": sub_config["product_id"],
125
+ "name": sub_config["reference_name"],
126
+ "subscriptionPeriod": duration,
127
+ "groupLevel": sub_config.get("group_level", 1),
128
+ "familySharable": sub_config.get("family_sharable", False),
129
+ "reviewNote": sub_config.get("review_note", ""),
130
+ },
131
+ "relationships": {
132
+ "group": {"data": {"type": "subscriptionGroups", "id": group_id}}
133
+ },
134
+ }
135
+ },
136
+ headers=headers,
137
+ timeout=TIMEOUT,
138
+ )
139
+ if not resp.ok:
140
+ print_api_errors(resp, f"create subscription '{sub_config['product_id']}'")
141
+ sys.exit(1)
142
+ sub_id = resp.json()["data"]["id"]
143
+ print(f" Created subscription '{sub_config['product_id']}' (ID: {sub_id})")
144
+ return sub_id
145
+
146
+
147
+ def get_subscription_localizations(headers: dict, sub_id: str) -> list:
148
+ """Fetch existing localizations for a subscription."""
149
+ resp = requests.get(
150
+ f"{BASE_URL}/subscriptions/{sub_id}/subscriptionLocalizations",
151
+ headers=headers,
152
+ timeout=TIMEOUT,
153
+ )
154
+ resp.raise_for_status()
155
+ return resp.json().get("data", [])
156
+
157
+
158
+ def create_localization(headers: dict, sub_id: str, locale: str, loc_data: dict) -> None:
159
+ """Create a new localization for a subscription."""
160
+ resp = requests.post(
161
+ f"{BASE_URL}/subscriptionLocalizations",
162
+ json={
163
+ "data": {
164
+ "type": "subscriptionLocalizations",
165
+ "attributes": {
166
+ "locale": locale,
167
+ "name": loc_data.get("name", ""),
168
+ "description": loc_data.get("description", ""),
169
+ },
170
+ "relationships": {
171
+ "subscription": {"data": {"type": "subscriptions", "id": sub_id}}
172
+ },
173
+ }
174
+ },
175
+ headers=headers,
176
+ timeout=TIMEOUT,
177
+ )
178
+ if not resp.ok:
179
+ print_api_errors(resp, f"create localization '{locale}' for subscription {sub_id}")
180
+ return
181
+ print(f" Created localization '{locale}'")
182
+
183
+
184
+ def update_localization(headers: dict, loc_id: str, loc_data: dict) -> None:
185
+ """Update an existing subscription localization."""
186
+ resp = requests.patch(
187
+ f"{BASE_URL}/subscriptionLocalizations/{loc_id}",
188
+ json={
189
+ "data": {
190
+ "type": "subscriptionLocalizations",
191
+ "id": loc_id,
192
+ "attributes": {
193
+ "name": loc_data.get("name", ""),
194
+ "description": loc_data.get("description", ""),
195
+ },
196
+ }
197
+ },
198
+ headers=headers,
199
+ timeout=TIMEOUT,
200
+ )
201
+ if not resp.ok:
202
+ print_api_errors(resp, f"update localization {loc_id}")
203
+ return
204
+ print(f" Updated localization (ID: {loc_id})")
205
+
206
+
207
+ def print_api_errors(resp, action: str) -> None:
208
+ """Print human-readable API error messages."""
209
+ try:
210
+ errors = resp.json().get("errors", [])
211
+ for err in errors:
212
+ detail = err.get("detail", err.get("title", "Unknown error"))
213
+ print(f"ERROR ({action}): {detail}", file=sys.stderr)
214
+ except (ValueError, KeyError):
215
+ print(f"ERROR ({action}): HTTP {resp.status_code} - {resp.text[:200]}", file=sys.stderr)
@@ -51,7 +51,7 @@ def get_access_token(sa_path: str) -> str:
51
51
  resp = requests.post(
52
52
  "https://oauth2.googleapis.com/token",
53
53
  data={
54
- "grant_type": "urn:ietf:params:oauth:grant_type:jwt-bearer",
54
+ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
55
55
  "assertion": signed,
56
56
  },
57
57
  timeout=TIMEOUT,
@@ -86,6 +86,29 @@ if [ "$READY" != "true" ]; then
86
86
  echo " 4. Upload at least one AAB manually for the first release"
87
87
  echo " 5. Grant the service account access to the app"
88
88
  echo ""
89
+ echo "::warning title=Google Play Not Ready::App setup incomplete. See job summary for missing steps."
90
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
91
+ cat >> "$GITHUB_STEP_SUMMARY" << SUMMARY
92
+ ## :warning: Google Play Not Ready for Automation
93
+
94
+ The app **$PACKAGE_NAME** is not yet configured for automated publishing.
95
+
96
+ ### Missing steps:
97
+ $MISSING_STEPS
98
+
99
+ ### How to fix:
100
+
101
+ 1. Go to [Google Play Console](https://play.google.com/console)
102
+ 2. Ensure the app (\`$PACKAGE_NAME\`) has been manually created
103
+ 3. Complete the **Store listing** (description, graphics, screenshots)
104
+ 4. Complete the **Content rating** questionnaire
105
+ 5. Complete **Pricing & distribution** settings
106
+ 6. Upload at least one AAB manually for the first release
107
+ 7. Grant the service account access to the app under **Users & permissions**
108
+
109
+ > Once all steps are completed, re-run this workflow and Google Play readiness will pass.
110
+ SUMMARY
111
+ fi
89
112
  echo "Google Play is NOT ready. CI cannot proceed."
90
113
  exit 1
91
114
  else
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bash
2
- # Requires link-fastlane.sh and install-fastlane.sh to have run first (workflow steps).
2
+ # Syncs Android IAPs to Google Play via direct API calls (Python script).
3
+ # Requires read-config.sh to have been sourced (provides PROJECT_ROOT, credentials, etc.).
3
4
  set -euo pipefail
4
5
 
5
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
7
  source "$SCRIPT_DIR/../common/read-config.sh"
7
8
  source "$SCRIPT_DIR/../common/ci-notify.sh"
8
9
 
10
+ echo "=== Android IAP Sync ==="
11
+
9
12
  # --- Check Google Play readiness ---
10
13
  if [ "${GOOGLE_PLAY_READY:-false}" != "true" ]; then
11
14
  echo "ERROR: Google Play not ready. Cannot sync IAPs." >&2
@@ -18,12 +21,6 @@ if [ ! -f "$IAP_CONFIG" ]; then
18
21
  ci_skip "No Android IAP config file found"
19
22
  fi
20
23
 
21
- # --- Check if IAP plugin is available ---
22
- cd "$APP_ROOT/android"
23
- if ! bundle exec gem list fastlane-plugin-iap --installed >/dev/null 2>&1; then
24
- ci_skip "fastlane-plugin-iap not installed"
25
- fi
26
-
27
24
  # --- Hash-based change detection ---
28
25
  STATE_DIR="$PROJECT_ROOT/.ci-state"
29
26
  mkdir -p "$STATE_DIR"
@@ -47,12 +44,19 @@ if [ ! -f "$SA_FULL_PATH" ]; then
47
44
  exit 1
48
45
  fi
49
46
 
50
- # --- Sync IAP via Fastlane ---
47
+ # --- Run IAP sync via Python ---
48
+ SYNC_SCRIPT="$PROJECT_ROOT/scripts/sync_iap_android.py"
49
+
50
+ if [ ! -f "$SYNC_SCRIPT" ]; then
51
+ echo "ERROR: sync_iap_android.py not found at $SYNC_SCRIPT" >&2
52
+ exit 1
53
+ fi
54
+
51
55
  echo "Syncing Android IAP configuration..."
52
56
 
57
+ SA_JSON="$SA_FULL_PATH" \
53
58
  PACKAGE_NAME="$PACKAGE_NAME" \
54
- GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH="$SA_FULL_PATH" \
55
- bundle exec fastlane sync_google_iap
59
+ python3 "$SYNC_SCRIPT" "$IAP_CONFIG"
56
60
 
57
61
  # --- Update hash on success ---
58
62
  echo "$HASH" > "$STATE_FILE"
@@ -15,12 +15,15 @@ if [ "${GOOGLE_PLAY_READY:-false}" != "true" ]; then
15
15
 
16
16
  AAB_DIR="$APP_ROOT/build/app/outputs/bundle/release"
17
17
  AAB_FILE=$(find "$AAB_DIR" -name "*.aab" -type f 2>/dev/null | head -1)
18
+ AAB_INFO=""
18
19
  if [ -n "$AAB_FILE" ]; then
20
+ AAB_INFO="Built AAB: $AAB_FILE ($(du -h "$AAB_FILE" | cut -f1))"
19
21
  echo ""
20
- echo "Built AAB: $AAB_FILE ($(du -h "$AAB_FILE" | cut -f1))"
22
+ echo "$AAB_INFO"
21
23
  else
24
+ AAB_INFO="No AAB found. Run build.sh first."
22
25
  echo ""
23
- echo "No AAB found. Run build.sh first."
26
+ echo "$AAB_INFO"
24
27
  fi
25
28
 
26
29
  echo ""
@@ -32,6 +35,28 @@ if [ "${GOOGLE_PLAY_READY:-false}" != "true" ]; then
32
35
  echo " 5. Complete the release form and roll out"
33
36
  echo ""
34
37
  echo "After the first manual upload, subsequent releases will be automated."
38
+ echo "::warning title=Manual AAB Upload Required::First release must be uploaded manually. See job summary for instructions."
39
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
40
+ cat >> "$GITHUB_STEP_SUMMARY" << SUMMARY
41
+ ## :warning: Manual AAB Upload Required - First Release
42
+
43
+ Google Play is not ready for automated binary uploads. This is expected for the first release.
44
+
45
+ **$AAB_INFO**
46
+
47
+ ### How to upload manually:
48
+
49
+ 1. Go to [Google Play Console](https://play.google.com/console)
50
+ 2. Select your app (\`$PACKAGE_NAME\`)
51
+ 3. Navigate to **Release > Testing > Internal testing** (or your target track)
52
+ 4. Click **Create new release**
53
+ 5. Upload the AAB file built by CI
54
+ 6. Fill in release notes
55
+ 7. Complete the release form and click **Start rollout**
56
+
57
+ > After the first manual upload, all subsequent binary uploads will be fully automated by CI.
58
+ SUMMARY
59
+ fi
35
60
  exit 1
36
61
  fi
37
62
 
@@ -58,6 +58,29 @@ if [ $FASTLANE_EXIT -ne 0 ]; then
58
58
  # published. This resolves after the first manual release via the Play
59
59
  # Console, so we warn instead of failing the workflow.
60
60
  if echo "$FASTLANE_OUTPUT" | grep -qi "draft app"; then
61
+ echo "::warning title=Google Play Draft App::First manual release required. See job summary for instructions."
62
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
63
+ cat >> "$GITHUB_STEP_SUMMARY" << 'SUMMARY'
64
+ ## :warning: Google Play Draft App - Manual Action Required
65
+
66
+ Metadata upload could not be committed because the app is still in **draft state** on Google Play.
67
+
68
+ ### How to fix:
69
+
70
+ 1. Go to [Google Play Console](https://play.google.com/console)
71
+ 2. Select your app
72
+ 3. Navigate to **Release > Production** (or **Internal testing**)
73
+ 4. Click **Create new release**
74
+ 5. The AAB was already uploaded by CI -- select it
75
+ 6. Fill in release notes
76
+ 7. Complete **Store listing** (description, screenshots -- already uploaded by CI)
77
+ 8. Complete **Content rating** questionnaire
78
+ 9. Complete **Pricing & distribution** settings
79
+ 10. Click **Review release** then **Start rollout**
80
+
81
+ > After the first manual release, all subsequent CI metadata uploads will commit successfully without this error.
82
+ SUMMARY
83
+ fi
61
84
  ci_skip "App is in draft status — manual first release required on Google Play Console"
62
85
  fi
63
86
  exit $FASTLANE_EXIT
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # Requires link-fastlane.sh and install-fastlane.sh to have run first (workflow steps).
2
+ # Syncs iOS IAPs to App Store Connect via direct API calls (Python script).
3
+ # Requires read-config.sh to have been sourced (provides PROJECT_ROOT, credentials, etc.).
3
4
  set -euo pipefail
4
5
 
5
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -15,15 +16,6 @@ if [ ! -f "$IAP_CONFIG" ]; then
15
16
  ci_skip "No iOS IAP config file found"
16
17
  fi
17
18
 
18
- # --- Check if IAP plugin is available ---
19
- cd "$APP_ROOT/ios"
20
-
21
- if ! bundle exec gem list fastlane-plugin-iap --installed >/dev/null 2>&1; then
22
- ci_skip "fastlane-plugin-iap not installed"
23
- fi
24
-
25
- echo "fastlane-plugin-iap is installed. Proceeding with sync."
26
-
27
19
  # --- Hash-based change detection ---
28
20
  CURRENT_HASH=$(shasum -a 256 "$IAP_CONFIG" | cut -d' ' -f1)
29
21
 
@@ -41,26 +33,30 @@ else
41
33
  echo "No cached hash found. First run — will sync IAPs."
42
34
  fi
43
35
 
44
- # --- Set up App Store Connect API key ---
36
+ # --- Set up App Store Connect API credentials ---
45
37
  P8_FULL_PATH="$PROJECT_ROOT/$P8_KEY_PATH"
46
38
  if [ ! -f "$P8_FULL_PATH" ]; then
47
39
  echo "ERROR: P8 key file not found at $P8_FULL_PATH" >&2
48
40
  exit 1
49
41
  fi
50
42
 
51
- export APP_STORE_CONNECT_API_KEY_KEY_ID="$APPLE_KEY_ID"
52
- export APP_STORE_CONNECT_API_KEY_ISSUER_ID="$APPLE_ISSUER_ID"
53
- export APP_STORE_CONNECT_API_KEY_KEY="$(cat "$P8_FULL_PATH")"
54
- export APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64="false"
43
+ export APP_STORE_CONNECT_KEY_IDENTIFIER="$APPLE_KEY_ID"
44
+ export APP_STORE_CONNECT_ISSUER_ID="$APPLE_ISSUER_ID"
45
+ export APP_STORE_CONNECT_PRIVATE_KEY="$(cat "$P8_FULL_PATH")"
46
+ export BUNDLE_ID="$BUNDLE_ID"
55
47
 
56
48
  echo "ASC API key configured (Key ID: $APPLE_KEY_ID)"
57
49
 
58
- # --- Run IAP sync ---
59
- echo "Syncing IAPs to App Store Connect..."
50
+ # --- Run IAP sync via Python ---
51
+ SYNC_SCRIPT="$PROJECT_ROOT/scripts/sync_iap_ios.py"
60
52
 
61
- FASTLANE_API_KEY_PATH="$P8_FULL_PATH" \
62
- BUNDLE_ID="$BUNDLE_ID" \
63
- bundle exec fastlane sync_iap
53
+ if [ ! -f "$SYNC_SCRIPT" ]; then
54
+ echo "ERROR: sync_iap_ios.py not found at $SYNC_SCRIPT" >&2
55
+ exit 1
56
+ fi
57
+
58
+ echo "Syncing IAPs to App Store Connect..."
59
+ python3 "$SYNC_SCRIPT" "$IAP_CONFIG"
64
60
 
65
61
  # --- Update hash on success ---
66
62
  echo "$CURRENT_HASH" > "$STATE_FILE"
@@ -0,0 +1,189 @@
1
+ """
2
+ Google Play IAP API layer.
3
+
4
+ Low-level functions for interacting with the Android Publisher API
5
+ for subscriptions, base plans, and offers.
6
+ """
7
+ import json
8
+ import sys
9
+ import time
10
+
11
+ try:
12
+ import jwt
13
+ import requests
14
+ except ImportError:
15
+ import subprocess
16
+ subprocess.check_call(
17
+ [sys.executable, "-m", "pip", "install", "--break-system-packages", "PyJWT", "cryptography", "requests"],
18
+ stdout=subprocess.DEVNULL,
19
+ )
20
+ import jwt
21
+ import requests
22
+
23
+ API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
24
+ TIMEOUT = (10, 30)
25
+
26
+ # ISO 8601 duration mapping (normalize to ISO 8601 for Google Play)
27
+ DURATION_MAP = {
28
+ "ONE_WEEK": "P1W",
29
+ "ONE_MONTH": "P1M",
30
+ "TWO_MONTHS": "P2M",
31
+ "THREE_MONTHS": "P3M",
32
+ "SIX_MONTHS": "P6M",
33
+ "ONE_YEAR": "P1Y",
34
+ }
35
+
36
+ # Regions version required by the API
37
+ REGIONS_VERSION = {"version": "2022/02"}
38
+
39
+
40
+ def get_access_token(sa_path: str) -> str:
41
+ """Obtain an OAuth2 access token using the service account credentials."""
42
+ with open(sa_path, "r", encoding="utf-8") as fh:
43
+ sa = json.load(fh)
44
+ now = int(time.time())
45
+ payload = {
46
+ "iss": sa["client_email"],
47
+ "scope": "https://www.googleapis.com/auth/androidpublisher",
48
+ "aud": "https://oauth2.googleapis.com/token",
49
+ "iat": now,
50
+ "exp": now + 3600,
51
+ }
52
+ signed = jwt.encode(payload, sa["private_key"], algorithm="RS256")
53
+ resp = requests.post(
54
+ "https://oauth2.googleapis.com/token",
55
+ data={"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": signed},
56
+ timeout=TIMEOUT,
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()["access_token"]
60
+
61
+
62
+ def list_subscriptions(headers: dict, package_name: str) -> dict:
63
+ """List all existing subscriptions. Returns a dict keyed by productId."""
64
+ resp = requests.get(
65
+ f"{API_BASE}/{package_name}/subscriptions",
66
+ headers=headers,
67
+ timeout=TIMEOUT,
68
+ )
69
+ if resp.status_code == 404:
70
+ return {}
71
+ resp.raise_for_status()
72
+ if not resp.text.strip():
73
+ return {}
74
+ subs = resp.json().get("subscriptions", [])
75
+ return {s["productId"]: s for s in subs}
76
+
77
+
78
+ def normalize_duration(duration: str) -> str:
79
+ """Normalize duration to ISO 8601 format accepted by Google Play."""
80
+ return DURATION_MAP.get(duration, duration)
81
+
82
+
83
+ def build_price(price_str: str, currency: str = "USD") -> dict:
84
+ """Build a Money object from a decimal price string like '9.99'."""
85
+ parts = price_str.split(".")
86
+ units = parts[0]
87
+ nanos = 0
88
+ if len(parts) > 1:
89
+ frac = parts[1].ljust(9, "0")[:9]
90
+ nanos = int(frac)
91
+ return {"currencyCode": currency, "units": units, "nanos": nanos}
92
+
93
+
94
+ def currency_to_region(currency: str) -> str:
95
+ """Map common currency codes to region codes."""
96
+ mapping = {
97
+ "USD": "US",
98
+ "EUR": "DE",
99
+ "GBP": "GB",
100
+ "JPY": "JP",
101
+ "CAD": "CA",
102
+ "AUD": "AU",
103
+ }
104
+ return mapping.get(currency, "")
105
+
106
+
107
+ def create_subscription(headers: dict, package_name: str, product_id: str, body: dict) -> dict:
108
+ """Create a new subscription via the API."""
109
+ resp = requests.post(
110
+ f"{API_BASE}/{package_name}/subscriptions",
111
+ params={"productId": product_id, "regionsVersion.version": REGIONS_VERSION["version"]},
112
+ json=body,
113
+ headers=headers,
114
+ timeout=TIMEOUT,
115
+ )
116
+ if not resp.ok:
117
+ print_api_error(resp, f"create subscription '{product_id}'")
118
+ return {}
119
+
120
+ print(f" Created subscription '{product_id}'")
121
+ return resp.json()
122
+
123
+
124
+ def update_subscription(headers: dict, package_name: str, product_id: str, body: dict) -> dict:
125
+ """Update an existing subscription via the API."""
126
+ resp = requests.patch(
127
+ f"{API_BASE}/{package_name}/subscriptions/{product_id}",
128
+ params={
129
+ "updateMask": "listings",
130
+ "regionsVersion.version": REGIONS_VERSION["version"],
131
+ },
132
+ json=body,
133
+ headers=headers,
134
+ timeout=TIMEOUT,
135
+ )
136
+ if not resp.ok:
137
+ print_api_error(resp, f"update subscription '{product_id}'")
138
+ return {}
139
+
140
+ print(f" Updated subscription '{product_id}'")
141
+ return resp.json()
142
+
143
+
144
+ def activate_base_plan(headers: dict, package_name: str, product_id: str, base_plan_id: str) -> bool:
145
+ """Activate a base plan for a subscription."""
146
+ resp = requests.post(
147
+ f"{API_BASE}/{package_name}/subscriptions/{product_id}/basePlans/{base_plan_id}:activate",
148
+ headers=headers,
149
+ json={},
150
+ timeout=TIMEOUT,
151
+ )
152
+ if not resp.ok:
153
+ print_api_error(resp, f"activate base plan '{base_plan_id}'")
154
+ return False
155
+ print(f" Activated base plan '{base_plan_id}'")
156
+ return True
157
+
158
+
159
+ def create_intro_offer(
160
+ headers: dict, package_name: str, product_id: str, base_plan_id: str, offer_id: str, body: dict
161
+ ) -> bool:
162
+ """Create an introductory offer (free trial) for a base plan."""
163
+ resp = requests.post(
164
+ f"{API_BASE}/{package_name}/subscriptions/{product_id}"
165
+ f"/basePlans/{base_plan_id}/offers",
166
+ params={"offerId": offer_id, "regionsVersion.version": REGIONS_VERSION["version"]},
167
+ json=body,
168
+ headers=headers,
169
+ timeout=TIMEOUT,
170
+ )
171
+ if resp.status_code == 409:
172
+ print(f" Intro offer '{offer_id}' already exists")
173
+ return True
174
+ if not resp.ok:
175
+ print_api_error(resp, f"create intro offer for '{product_id}'")
176
+ return False
177
+
178
+ print(f" Created intro offer '{offer_id}'")
179
+ return True
180
+
181
+
182
+ def print_api_error(resp, action: str) -> None:
183
+ """Print human-readable API error messages."""
184
+ try:
185
+ error_data = resp.json()
186
+ message = error_data.get("error", {}).get("message", resp.text[:200])
187
+ print(f"ERROR ({action}): {message}", file=sys.stderr)
188
+ except (ValueError, KeyError):
189
+ print(f"ERROR ({action}): HTTP {resp.status_code} - {resp.text[:200]}", file=sys.stderr)
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sync Android In-App Purchases to Google Play via the Android Publisher API.
4
+
5
+ Reads iap_config.json and creates/updates subscriptions with base plans and offers.
6
+ Idempotent: safe to run repeatedly -- existing resources are skipped or updated.
7
+
8
+ Required env vars:
9
+ SA_JSON - Path to Google service account JSON file
10
+ PACKAGE_NAME - Android package name (e.g. com.example.app)
11
+
12
+ Usage:
13
+ python3 sync_iap_android.py <path/to/iap_config.json>
14
+ """
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ from gplay_iap_api import (
20
+ activate_base_plan,
21
+ build_price,
22
+ create_intro_offer,
23
+ create_subscription,
24
+ currency_to_region,
25
+ get_access_token,
26
+ list_subscriptions,
27
+ normalize_duration,
28
+ update_subscription,
29
+ )
30
+
31
+
32
+ def _build_base_plan(sub_config: dict) -> dict:
33
+ """Build a base plan object from subscription config."""
34
+ duration = normalize_duration(sub_config["duration"])
35
+ prices = sub_config.get("prices", {})
36
+ usd_price = prices.get("USD", sub_config.get("price_tier", "0"))
37
+ eur_price = prices.get("EUR", usd_price)
38
+
39
+ regional_configs = []
40
+ for currency, amount in prices.items():
41
+ region = currency_to_region(currency)
42
+ if region:
43
+ regional_configs.append({
44
+ "regionCode": region,
45
+ "newSubscriberAvailability": True,
46
+ "price": build_price(amount, currency),
47
+ })
48
+
49
+ return {
50
+ "basePlanId": sub_config["product_id"].replace(".", "-").replace("_", "-"),
51
+ "autoRenewingBasePlanType": {
52
+ "billingPeriodDuration": duration,
53
+ "gracePeriodDuration": "P3D",
54
+ "resubscribeState": "RESUBSCRIBE_STATE_ACTIVE",
55
+ "legacyCompatible": True,
56
+ },
57
+ "regionalConfigs": regional_configs,
58
+ "otherRegionsConfig": {
59
+ "usdPrice": build_price(usd_price, "USD"),
60
+ "eurPrice": build_price(eur_price, "EUR"),
61
+ "newSubscriberAvailability": True,
62
+ },
63
+ }
64
+
65
+
66
+ def _build_listings(sub_config: dict) -> list:
67
+ """Build localized listings from subscription config."""
68
+ localizations = sub_config.get("localizations", {})
69
+ listings = []
70
+
71
+ if localizations:
72
+ for lang_code, loc_data in localizations.items():
73
+ listings.append({
74
+ "languageCode": lang_code,
75
+ "title": loc_data.get("name", sub_config["reference_name"]),
76
+ "description": loc_data.get("description", ""),
77
+ "benefits": loc_data.get("benefits", []),
78
+ })
79
+ else:
80
+ listings.append({
81
+ "languageCode": "en-US",
82
+ "title": sub_config["reference_name"],
83
+ "description": sub_config.get("description", ""),
84
+ "benefits": [],
85
+ })
86
+
87
+ return listings
88
+
89
+
90
+ def _build_subscription_body(sub_config: dict, package_name: str) -> dict:
91
+ """Build the full subscription request body."""
92
+ return {
93
+ "packageName": package_name,
94
+ "productId": sub_config["product_id"],
95
+ "basePlans": [_build_base_plan(sub_config)],
96
+ "listings": _build_listings(sub_config),
97
+ }
98
+
99
+
100
+ def _build_intro_offer_body(sub_config: dict) -> dict:
101
+ """Build the introductory offer request body from subscription config."""
102
+ intro = sub_config["introductory_offer"]
103
+ offer_type = intro.get("type", "FREE")
104
+ duration = normalize_duration(intro.get("duration", "P1W"))
105
+
106
+ phases = [{"recurrenceCount": intro.get("periods", 1), "duration": duration}]
107
+
108
+ if offer_type in ("FREE", "FREE_TRIAL"):
109
+ phases[0]["regionalConfigs"] = [
110
+ {"regionCode": "US", "price": build_price("0", "USD")}
111
+ ]
112
+ else:
113
+ price = intro.get("price", "0")
114
+ phases[0]["regionalConfigs"] = [
115
+ {"regionCode": "US", "price": build_price(price, "USD")}
116
+ ]
117
+
118
+ offer_id = f"{sub_config['product_id'].replace('.', '-').replace('_', '-')}-intro"
119
+ return {
120
+ "offerId": offer_id,
121
+ "phases": phases,
122
+ "targeting": {
123
+ "acquisitionRule": {
124
+ "scope": {"thisSubscription": {}}
125
+ }
126
+ },
127
+ "regionalConfigs": [{"regionCode": "US", "newSubscriberAvailability": True}],
128
+ }
129
+
130
+
131
+ def sync_subscription(headers: dict, package_name: str, sub_config: dict, existing: dict) -> dict:
132
+ """Sync a single subscription: create if missing, update if exists."""
133
+ product_id = sub_config["product_id"]
134
+ print(f"\n Processing subscription: {product_id}")
135
+ body = _build_subscription_body(sub_config, package_name)
136
+
137
+ if product_id in existing:
138
+ update_subscription(headers, package_name, product_id, body)
139
+ return {"product_id": product_id, "action": "updated"}
140
+
141
+ result = create_subscription(headers, package_name, product_id, body)
142
+ if not result:
143
+ return {"product_id": product_id, "action": "failed"}
144
+
145
+ base_plan_id = product_id.replace(".", "-").replace("_", "-")
146
+ activate_base_plan(headers, package_name, product_id, base_plan_id)
147
+
148
+ if sub_config.get("introductory_offer"):
149
+ offer_body = _build_intro_offer_body(sub_config)
150
+ offer_id = f"{product_id.replace('.', '-').replace('_', '-')}-intro"
151
+ create_intro_offer(headers, package_name, product_id, base_plan_id, offer_id, offer_body)
152
+
153
+ return {"product_id": product_id, "action": "created"}
154
+
155
+
156
+ def validate_env() -> tuple:
157
+ """Validate required environment variables. Returns (sa_path, package_name)."""
158
+ sa_json = os.environ.get("SA_JSON", "")
159
+ package_name = os.environ.get("PACKAGE_NAME", "")
160
+
161
+ if not sa_json or not package_name:
162
+ print("ERROR: SA_JSON and PACKAGE_NAME env vars are required", file=sys.stderr)
163
+ sys.exit(1)
164
+
165
+ if not os.path.isfile(sa_json):
166
+ print(f"ERROR: Service account file not found: {sa_json}", file=sys.stderr)
167
+ sys.exit(1)
168
+
169
+ return sa_json, package_name
170
+
171
+
172
+ def load_iap_config(config_path: str) -> dict:
173
+ """Load and validate the IAP config file."""
174
+ if not os.path.isfile(config_path):
175
+ print(f"ERROR: IAP config file not found: {config_path}", file=sys.stderr)
176
+ sys.exit(1)
177
+
178
+ with open(config_path, "r", encoding="utf-8") as f:
179
+ config = json.load(f)
180
+
181
+ if not config.get("subscription_groups"):
182
+ print("WARNING: No subscription_groups found in config", file=sys.stderr)
183
+
184
+ return config
185
+
186
+
187
+ def main() -> None:
188
+ if len(sys.argv) < 2:
189
+ print(f"Usage: {sys.argv[0]} <path/to/iap_config.json>", file=sys.stderr)
190
+ sys.exit(1)
191
+
192
+ config_path = sys.argv[1]
193
+ sa_json, package_name = validate_env()
194
+
195
+ access_token = get_access_token(sa_json)
196
+ headers = {
197
+ "Authorization": f"Bearer {access_token}",
198
+ "Content-Type": "application/json",
199
+ }
200
+
201
+ config = load_iap_config(config_path)
202
+ print(f"Package: {package_name}")
203
+
204
+ existing = list_subscriptions(headers, package_name)
205
+ print(f"Found {len(existing)} existing subscription(s)")
206
+
207
+ results = []
208
+ for group in config.get("subscription_groups", []):
209
+ group_name = group.get("reference_name", group.get("group_name", "Unknown"))
210
+ print(f"\nProcessing group: {group_name}")
211
+ for sub_config in group.get("subscriptions", []):
212
+ result = sync_subscription(headers, package_name, sub_config, existing)
213
+ results.append(result)
214
+
215
+ print(f"\n{json.dumps({'synced_subscriptions': results}, indent=2)}")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sync iOS In-App Purchases to App Store Connect via the REST API.
4
+
5
+ Reads iap_config.json and creates/updates subscription groups and subscriptions.
6
+ Idempotent: safe to run repeatedly -- existing resources are skipped or updated.
7
+
8
+ Required env vars:
9
+ APP_STORE_CONNECT_KEY_IDENTIFIER - Key ID from App Store Connect
10
+ APP_STORE_CONNECT_ISSUER_ID - Issuer ID from App Store Connect
11
+ APP_STORE_CONNECT_PRIVATE_KEY - Contents of the P8 key file
12
+ BUNDLE_ID - App bundle identifier
13
+
14
+ Usage:
15
+ python3 sync_iap_ios.py <path/to/iap_config.json>
16
+ """
17
+ import json
18
+ import os
19
+ import sys
20
+
21
+ from asc_iap_api import (
22
+ create_localization,
23
+ create_subscription,
24
+ create_subscription_group,
25
+ get_app_id,
26
+ get_jwt_token,
27
+ get_subscription_localizations,
28
+ list_subscription_groups,
29
+ list_subscriptions_in_group,
30
+ update_localization,
31
+ )
32
+
33
+
34
+ def find_or_create_group(headers: dict, app_id: str, reference_name: str, existing_groups: list) -> str:
35
+ """Find an existing subscription group by reference name or create a new one."""
36
+ for group in existing_groups:
37
+ if group["attributes"]["referenceName"] == reference_name:
38
+ group_id = group["id"]
39
+ print(f" Subscription group '{reference_name}' already exists (ID: {group_id})")
40
+ return group_id
41
+ return create_subscription_group(headers, app_id, reference_name)
42
+
43
+
44
+ def find_or_create_subscription(headers: dict, group_id: str, sub_config: dict, existing_subs: list) -> str:
45
+ """Find an existing subscription by product ID or create a new one."""
46
+ product_id = sub_config["product_id"]
47
+ for sub in existing_subs:
48
+ if sub["attributes"]["productId"] == product_id:
49
+ sub_id = sub["id"]
50
+ print(f" Subscription '{product_id}' already exists (ID: {sub_id})")
51
+ return sub_id
52
+ return create_subscription(headers, group_id, sub_config)
53
+
54
+
55
+ def set_subscription_localization(headers: dict, sub_id: str, locale: str, loc_data: dict) -> None:
56
+ """Create or update a localization for a subscription."""
57
+ existing = get_subscription_localizations(headers, sub_id)
58
+ for loc in existing:
59
+ if loc["attributes"]["locale"] == locale:
60
+ update_localization(headers, loc["id"], loc_data)
61
+ return
62
+ create_localization(headers, sub_id, locale, loc_data)
63
+
64
+
65
+ def sync_subscription_group(headers: dict, app_id: str, group_config: dict, existing_groups: list) -> dict:
66
+ """Sync a single subscription group and its subscriptions. Returns sync result."""
67
+ ref_name = group_config.get("reference_name", group_config.get("group_name", ""))
68
+ if not ref_name:
69
+ print("WARNING: Subscription group missing reference_name, skipping", file=sys.stderr)
70
+ return {"group": ref_name, "status": "skipped", "subscriptions": []}
71
+
72
+ print(f"\nProcessing subscription group: {ref_name}")
73
+ group_id = find_or_create_group(headers, app_id, ref_name, existing_groups)
74
+
75
+ existing_subs = list_subscriptions_in_group(headers, group_id)
76
+ sub_results = []
77
+
78
+ for sub_config in group_config.get("subscriptions", []):
79
+ sub_id = find_or_create_subscription(headers, group_id, sub_config, existing_subs)
80
+ _sync_subscription_localizations(headers, sub_id, sub_config)
81
+ sub_results.append({"product_id": sub_config["product_id"], "id": sub_id})
82
+
83
+ return {"group": ref_name, "group_id": group_id, "subscriptions": sub_results}
84
+
85
+
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:
90
+ return
91
+ for locale, loc_data in localizations.items():
92
+ set_subscription_localization(headers, sub_id, locale, loc_data)
93
+
94
+
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
+
112
+ if missing:
113
+ print(f"ERROR: Missing required environment variables: {', '.join(missing)}", file=sys.stderr)
114
+ sys.exit(1)
115
+
116
+ return key_id, issuer_id, private_key, bundle_id
117
+
118
+
119
+ def load_iap_config(config_path: str) -> dict:
120
+ """Load and validate the IAP config file."""
121
+ if not os.path.isfile(config_path):
122
+ print(f"ERROR: IAP config file not found: {config_path}", file=sys.stderr)
123
+ sys.exit(1)
124
+
125
+ with open(config_path, "r", encoding="utf-8") as f:
126
+ config = json.load(f)
127
+
128
+ if not config.get("subscription_groups"):
129
+ print("WARNING: No subscription_groups found in config", file=sys.stderr)
130
+
131
+ return config
132
+
133
+
134
+ def main() -> None:
135
+ if len(sys.argv) < 2:
136
+ print(f"Usage: {sys.argv[0]} <path/to/iap_config.json>", file=sys.stderr)
137
+ sys.exit(1)
138
+
139
+ config_path = sys.argv[1]
140
+ key_id, issuer_id, private_key, bundle_id = validate_env()
141
+
142
+ token = get_jwt_token(key_id, issuer_id, private_key)
143
+ headers = {
144
+ "Authorization": f"Bearer {token}",
145
+ "Content-Type": "application/json",
146
+ }
147
+
148
+ config = load_iap_config(config_path)
149
+ app_id = get_app_id(headers, bundle_id)
150
+ print(f"App ID: {app_id} (Bundle: {bundle_id})")
151
+
152
+ existing_groups = list_subscription_groups(headers, app_id)
153
+ results = []
154
+
155
+ for group_config in config.get("subscription_groups", []):
156
+ result = sync_subscription_group(headers, app_id, group_config, existing_groups)
157
+ results.append(result)
158
+
159
+ print(f"\n{json.dumps({'synced_groups': results}, indent=2)}")
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()