Procházet zdrojové kódy

Add App Review notes and App Store Connect subscription tooling.

App Review (review-notes.txt)
- Add copy-ready notes for App Store Connect → App Review Information,
  structured like App for Linked: screen recording guidance, app purpose,
  reviewer test flow, external services, regional/regulated disclaimers,
  and sandbox entitlement explanations.
- Documents free tier (two Home AI messages) vs Pro, embedded Indeed
  browsing, CV Maker/profile/PDF export, and StoreKit sandbox testing.

Subscription alignment
- Align Paywall.storekit and SubscriptionProductIDs.swift to
  com.hwaccount.appforindeed.pro.{weekly,monthly,yearly} so Xcode
  StoreKit Testing, runtime purchases, and App Store Connect use the
  same product IDs as bundle com.hwaccount.app-for-indeed.
- Bump StoreKit configuration schema to v4 and refresh Xcode defaults
  (billing flags, winback offer arrays).

Automation
- Add sync_subscriptions_from_storekit.py to create or update the ASC
  subscription group, products, localizations, USA pricing, and yearly
  free trial from Paywall.storekit; writes subscriptions.manifest.json.
- Document the sync command in app-connect/README.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
Uzair Tahir před 2 týdny
rodič
revize
6b50179c48

+ 25 - 11
App for Indeed/Paywall.storekit

@@ -17,8 +17,13 @@
17 17
 
18 18
   ],
19 19
   "settings" : {
20
+    "_askToBuyEnabled" : false,
21
+    "_billingGracePeriodEnabled" : false,
22
+    "_billingIssuesEnabled" : false,
23
+    "_disableDialogs" : false,
20 24
     "_failTransactionsEnabled" : false,
21 25
     "_locale" : "en_US",
26
+    "_renewalBillingIssuesEnabled" : false,
22 27
     "_storefront" : "USA",
23 28
     "_storeKitErrors" : [
24 29
 
@@ -27,7 +32,7 @@
27 32
   },
28 33
   "subscriptionGroups" : [
29 34
     {
30
-      "id" : "21829751",
35
+      "id" : "22102255",
31 36
       "localizations" : [
32 37
 
33 38
       ],
@@ -52,11 +57,14 @@
52 57
               "locale" : "en_US"
53 58
             }
54 59
           ],
55
-          "productID" : "com.mqldev.appforindeed.pro.weekly",
60
+          "productID" : "com.hwaccount.appforindeed.pro.weekly",
56 61
           "recurringSubscriptionPeriod" : "P1W",
57 62
           "referenceName" : "Weekly Pro",
58
-          "subscriptionGroupID" : "21829751",
59
-          "type" : "RecurringSubscription"
63
+          "subscriptionGroupID" : "22102255",
64
+          "type" : "RecurringSubscription",
65
+          "winbackOffers" : [
66
+
67
+          ]
60 68
         },
61 69
         {
62 70
           "adHocOffers" : [
@@ -77,11 +85,14 @@
77 85
               "locale" : "en_US"
78 86
             }
79 87
           ],
80
-          "productID" : "com.mqldev.appforindeed.pro.monthly",
88
+          "productID" : "com.hwaccount.appforindeed.pro.monthly",
81 89
           "recurringSubscriptionPeriod" : "P1M",
82 90
           "referenceName" : "Monthly Pro",
83
-          "subscriptionGroupID" : "21829751",
84
-          "type" : "RecurringSubscription"
91
+          "subscriptionGroupID" : "22102255",
92
+          "type" : "RecurringSubscription",
93
+          "winbackOffers" : [
94
+
95
+          ]
85 96
         },
86 97
         {
87 98
           "adHocOffers" : [
@@ -107,17 +118,20 @@
107 118
               "locale" : "en_US"
108 119
             }
109 120
           ],
110
-          "productID" : "com.mqldev.appforindeed.pro.yearly",
121
+          "productID" : "com.hwaccount.appforindeed.pro.yearly",
111 122
           "recurringSubscriptionPeriod" : "P1Y",
112 123
           "referenceName" : "Yearly Pro",
113
-          "subscriptionGroupID" : "21829751",
114
-          "type" : "RecurringSubscription"
124
+          "subscriptionGroupID" : "22102255",
125
+          "type" : "RecurringSubscription",
126
+          "winbackOffers" : [
127
+
128
+          ]
115 129
         }
116 130
       ]
117 131
     }
118 132
   ],
119 133
   "version" : {
120
-    "major" : 3,
134
+    "major" : 4,
121 135
     "minor" : 0
122 136
   }
123 137
 }

+ 3 - 3
App for Indeed/Subscription/SubscriptionProductIDs.swift

@@ -10,9 +10,9 @@ import Foundation
10 10
 /// Keep these strings identical in code, `Paywall.storekit`, and App Store Connect.
11 11
 /// Local Xcode runs use `App for Indeed/Paywall.storekit` (Run scheme → Options → StoreKit Configuration).
12 12
 enum SubscriptionProductIDs {
13
-    static let weekly = "com.mqldev.appforindeed.pro.weekly"
14
-    static let monthly = "com.mqldev.appforindeed.pro.monthly"
15
-    static let yearly = "com.mqldev.appforindeed.pro.yearly"
13
+    static let weekly = "com.hwaccount.appforindeed.pro.weekly"
14
+    static let monthly = "com.hwaccount.appforindeed.pro.monthly"
15
+    static let yearly = "com.hwaccount.appforindeed.pro.yearly"
16 16
 
17 17
     static let all: [String] = [weekly, monthly, yearly]
18 18
 

+ 1 - 1
App for Indeed/Subscription/SubscriptionStore.swift

@@ -161,7 +161,7 @@ enum SubscriptionStoreError: LocalizedError {
161 161
             return """
162 162
             For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose Paywall.storekit.
163 163
 
164
-            For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.mqldev.appforindeed.pro.*), then submit them with the app version.
164
+            For TestFlight / App Store: In App Store Connect, create auto-renewable subscriptions whose Product IDs exactly match SubscriptionProductIDs.swift (same spelling as com.hwaccount.appforindeed.pro.*), then submit them with the app version.
165 165
             """
166 166
         }
167 167
     }

+ 11 - 0
app-connect/README.md

@@ -53,6 +53,17 @@ Override version or locale: `APP_STORE_VERSION=1.0 APP_STORE_LOCALE=en-US python
53 53
 
54 54
 Custom Terms of Use (EULA URL) must still use Apple’s standard EULA or be set manually in App Store Connect if you need a custom license agreement.
55 55
 
56
+### Sync subscriptions from Paywall.storekit
57
+
58
+Creates or updates the subscription group and products to match `App for Indeed/Paywall.storekit` (IDs, localizations, USA prices, yearly free trial):
59
+
60
+```bash
61
+cd app-connect
62
+python3 scripts/sync_subscriptions_from_storekit.py
63
+```
64
+
65
+Writes `subscriptions.manifest.json` with App Store Connect resource IDs. Product IDs in code, StoreKit, and App Store Connect must stay identical.
66
+
56 67
 ## Loading `.env` in the shell
57 68
 
58 69
 ```bash

+ 60 - 0
app-connect/review-notes.txt

@@ -0,0 +1,60 @@
1
+1. Screen Recording
2
+
3
+Please see the attached screen recording and uploaded screenshots.
4
+The recording shows launch, Home AI job search, saving a job, embedded Indeed browsing, CV Maker, profile editing, PDF export, and the Pro paywall.
5
+
6
+2. App Purpose
7
+
8
+App for Indeed is a native macOS job-hunting companion. It is independent and not affiliated with Indeed, Inc.
9
+
10
+Users can:
11
+- Search jobs on Home with AI chat, follow-ups, and Role / Company / Skill shortcuts
12
+- Save listings and review them under Saved Jobs
13
+- Open Indeed job pages in an embedded browser (back, forward, reload, Home)
14
+- Use CV Maker templates with saved profiles and export a PDF
15
+- Manage multiple local profiles and app appearance in Settings
16
+
17
+3. How to Access and Test Features
18
+
19
+No in-app account registration is required.
20
+
21
+Test flow:
22
+- Launch the app (startup refreshes subscription status and StoreKit products)
23
+- On Home, send a job search prompt; try follow-ups or “Show more jobs”
24
+- Use Role / Company / Skill shortcuts, then send a query
25
+- Save a job from results; confirm it on Saved Jobs
26
+- Tap Apply or open Indeed in the sidebar; browse in the web view (sign in with a personal Indeed account if needed for apply pages)
27
+- In CV Maker, pick a template and tap Continue (Pro-gated), then create/edit a profile and export PDF (Pro-gated)
28
+- Open Settings for theme and legal/support links
29
+- Trigger the paywall: send a third Home message without Pro, tap Try Pro, or use Pro-gated CV/Profile steps
30
+- Test purchases with a Sandbox Apple ID or Xcode StoreKit Testing (`Paywall.storekit`)
31
+
32
+Free tier: up to two user job-search messages on Home; other browsing (Saved Jobs, CV gallery, Indeed web view) is available.
33
+
34
+Pro: unlimited Home AI search; full CV Maker, profiles, and PDF export.
35
+
36
+No app test credentials are required.
37
+
38
+4. External Services Used
39
+
40
+OpenAI API — Home job search and CV template catalog (with local fallback templates)
41
+
42
+Indeed web content — `WKWebView` for listings and apply flows
43
+
44
+Apple StoreKit — weekly, monthly, and yearly auto-renewing Pro subscriptions
45
+
46
+5. Regional Differences
47
+
48
+Consistent across regions; no region-specific features.
49
+
50
+6. Regulated Industry
51
+
52
+Not a regulated-industry app (no financial, medical, or legal services).
53
+
54
+7. App Sandbox Entitlements (`App for Indeed.entitlements`)
55
+
56
+com.apple.security.app-sandbox — Required for Mac App Store sandboxing.
57
+
58
+com.apple.security.network.client — OpenAI requests, Indeed web content, and StoreKit/App Store.
59
+
60
+com.apple.security.files.user-selected.read-write — `NSSavePanel` PDF export to user-chosen folders under sandbox rules.

+ 374 - 0
app-connect/scripts/sync_subscriptions_from_storekit.py

@@ -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())