Browse Source

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 2 tháng trước cách đây
mục cha
commit
6b50179c48

+ 25 - 11
App for Indeed/Paywall.storekit

@@ -17,8 +17,13 @@
 
   ],
   "settings" : {
+    "_askToBuyEnabled" : false,
+    "_billingGracePeriodEnabled" : false,
+    "_billingIssuesEnabled" : false,
+    "_disableDialogs" : false,
     "_failTransactionsEnabled" : false,
     "_locale" : "en_US",
+    "_renewalBillingIssuesEnabled" : false,
     "_storefront" : "USA",
     "_storeKitErrors" : [
 
@@ -27,7 +32,7 @@
   },
   "subscriptionGroups" : [
     {
-      "id" : "21829751",
+      "id" : "22102255",
       "localizations" : [
 
       ],
@@ -52,11 +57,14 @@
               "locale" : "en_US"
             }
           ],
-          "productID" : "com.mqldev.appforindeed.pro.weekly",
+          "productID" : "com.hwaccount.appforindeed.pro.weekly",
           "recurringSubscriptionPeriod" : "P1W",
           "referenceName" : "Weekly Pro",
-          "subscriptionGroupID" : "21829751",
-          "type" : "RecurringSubscription"
+          "subscriptionGroupID" : "22102255",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
         },
         {
           "adHocOffers" : [
@@ -77,11 +85,14 @@
               "locale" : "en_US"
             }
           ],
-          "productID" : "com.mqldev.appforindeed.pro.monthly",
+          "productID" : "com.hwaccount.appforindeed.pro.monthly",
           "recurringSubscriptionPeriod" : "P1M",
           "referenceName" : "Monthly Pro",
-          "subscriptionGroupID" : "21829751",
-          "type" : "RecurringSubscription"
+          "subscriptionGroupID" : "22102255",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
         },
         {
           "adHocOffers" : [
@@ -107,17 +118,20 @@
               "locale" : "en_US"
             }
           ],
-          "productID" : "com.mqldev.appforindeed.pro.yearly",
+          "productID" : "com.hwaccount.appforindeed.pro.yearly",
           "recurringSubscriptionPeriod" : "P1Y",
           "referenceName" : "Yearly Pro",
-          "subscriptionGroupID" : "21829751",
-          "type" : "RecurringSubscription"
+          "subscriptionGroupID" : "22102255",
+          "type" : "RecurringSubscription",
+          "winbackOffers" : [
+
+          ]
         }
       ]
     }
   ],
   "version" : {
-    "major" : 3,
+    "major" : 4,
     "minor" : 0
   }
 }

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

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

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

@@ -161,7 +161,7 @@ enum SubscriptionStoreError: LocalizedError {
             return """
             For local testing in Xcode: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration → choose Paywall.storekit.
 
-            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.
+            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.
             """
         }
     }

+ 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
 
 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.
 
+### Sync subscriptions from Paywall.storekit
+
+Creates or updates the subscription group and products to match `App for Indeed/Paywall.storekit` (IDs, localizations, USA prices, yearly free trial):
+
+```bash
+cd app-connect
+python3 scripts/sync_subscriptions_from_storekit.py
+```
+
+Writes `subscriptions.manifest.json` with App Store Connect resource IDs. Product IDs in code, StoreKit, and App Store Connect must stay identical.
+
 ## Loading `.env` in the shell
 
 ```bash

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

@@ -0,0 +1,60 @@
+1. Screen Recording
+
+Please see the attached screen recording and uploaded screenshots.
+The recording shows launch, Home AI job search, saving a job, embedded Indeed browsing, CV Maker, profile editing, PDF export, and the Pro paywall.
+
+2. App Purpose
+
+App for Indeed is a native macOS job-hunting companion. It is independent and not affiliated with Indeed, Inc.
+
+Users can:
+- Search jobs on Home with AI chat, follow-ups, and Role / Company / Skill shortcuts
+- Save listings and review them under Saved Jobs
+- Open Indeed job pages in an embedded browser (back, forward, reload, Home)
+- Use CV Maker templates with saved profiles and export a PDF
+- Manage multiple local profiles and app appearance in Settings
+
+3. How to Access and Test Features
+
+No in-app account registration is required.
+
+Test flow:
+- Launch the app (startup refreshes subscription status and StoreKit products)
+- On Home, send a job search prompt; try follow-ups or “Show more jobs”
+- Use Role / Company / Skill shortcuts, then send a query
+- Save a job from results; confirm it on Saved Jobs
+- 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)
+- In CV Maker, pick a template and tap Continue (Pro-gated), then create/edit a profile and export PDF (Pro-gated)
+- Open Settings for theme and legal/support links
+- Trigger the paywall: send a third Home message without Pro, tap Try Pro, or use Pro-gated CV/Profile steps
+- Test purchases with a Sandbox Apple ID or Xcode StoreKit Testing (`Paywall.storekit`)
+
+Free tier: up to two user job-search messages on Home; other browsing (Saved Jobs, CV gallery, Indeed web view) is available.
+
+Pro: unlimited Home AI search; full CV Maker, profiles, and PDF export.
+
+No app test credentials are required.
+
+4. External Services Used
+
+OpenAI API — Home job search and CV template catalog (with local fallback templates)
+
+Indeed web content — `WKWebView` for listings and apply flows
+
+Apple StoreKit — weekly, monthly, and yearly auto-renewing Pro subscriptions
+
+5. Regional Differences
+
+Consistent across regions; no region-specific features.
+
+6. Regulated Industry
+
+Not a regulated-industry app (no financial, medical, or legal services).
+
+7. App Sandbox Entitlements (`App for Indeed.entitlements`)
+
+com.apple.security.app-sandbox — Required for Mac App Store sandboxing.
+
+com.apple.security.network.client — OpenAI requests, Indeed web content, and StoreKit/App Store.
+
+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 @@
+#!/usr/bin/env python3
+"""Sync App Store Connect subscriptions from Paywall.storekit."""
+
+from __future__ import annotations
+
+import json
+import sys
+import time
+from pathlib import Path
+
+import jwt
+import requests
+
+API_BASE = "https://api.appstoreconnect.apple.com/v1"
+ROOT = Path(__file__).resolve().parents[1]
+REPO_ROOT = ROOT.parent
+ENV_PATH = ROOT / ".env"
+STOREKIT_PATH = REPO_ROOT / "App for Indeed" / "Paywall.storekit"
+MANIFEST_PATH = ROOT / "subscriptions.manifest.json"
+BASE_TERRITORY = "USA"
+LOCALE = "en-US"
+
+
+def load_env(path: Path) -> dict[str, str]:
+    values: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        line = line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        values[key.strip()] = value.strip()
+    return values
+
+
+def storekit_locale_to_asc(locale: str) -> str:
+    return locale.replace("_", "-")
+
+
+def period_from_iso8601(value: str) -> str:
+    mapping = {
+        "P1W": "ONE_WEEK",
+        "P1M": "ONE_MONTH",
+        "P1Y": "ONE_YEAR",
+    }
+    if value not in mapping:
+        raise ValueError(f"Unsupported subscription period {value!r}")
+    return mapping[value]
+
+
+class ASCClient:
+    def __init__(self, issuer_id: str, key_id: str, private_key_path: str):
+        self.issuer_id = issuer_id
+        self.key_id = key_id
+        self.private_key = Path(private_key_path).expanduser().read_text(encoding="utf-8")
+        self.session = requests.Session()
+        self._token: str | None = None
+        self._token_exp = 0.0
+
+    def _ensure_token(self) -> str:
+        now = time.time()
+        if not self._token or now >= self._token_exp - 60:
+            issued_at = int(now)
+            payload = {
+                "iss": self.issuer_id,
+                "iat": issued_at,
+                "exp": issued_at + 1200,
+                "aud": "appstoreconnect-v1",
+            }
+            self._token = jwt.encode(
+                payload,
+                self.private_key,
+                algorithm="ES256",
+                headers={"kid": self.key_id, "typ": "JWT"},
+            )
+            self._token_exp = issued_at + 1200
+        return self._token
+
+    def request(self, method: str, path: str, **kwargs) -> requests.Response:
+        headers = kwargs.pop("headers", {})
+        headers["Authorization"] = f"Bearer {self._ensure_token()}"
+        headers.setdefault("Content-Type", "application/json")
+        url = path if path.startswith("http") else f"{API_BASE}{path}"
+        return self.session.request(method, url, headers=headers, timeout=90, **kwargs)
+
+    def get_json(self, path: str, params: dict | None = None) -> dict:
+        response = self.request("GET", path, params=params)
+        if not response.ok:
+            raise RuntimeError(f"GET {path} failed ({response.status_code}): {response.text}")
+        return response.json()
+
+    def post(self, resource_type: str, attributes: dict, relationships: dict) -> dict:
+        body = {
+            "data": {
+                "type": resource_type,
+                "attributes": attributes,
+                "relationships": relationships,
+            }
+        }
+        collection = resource_type
+        response = self.request("POST", f"/{collection}", json=body)
+        if not response.ok:
+            raise RuntimeError(
+                f"POST {resource_type} failed ({response.status_code}): {response.text}"
+            )
+        return response.json()["data"]
+
+
+def find_app(client: ASCClient, bundle_id: str) -> dict:
+    data = client.get_json("/apps", params={"filter[bundleId]": bundle_id, "limit": 1})
+    apps = data.get("data", [])
+    if not apps:
+        raise RuntimeError(f"No app found for bundle id {bundle_id!r}")
+    return apps[0]
+
+
+def load_storekit(path: Path) -> dict:
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def list_subscription_groups(client: ASCClient, app_id: str) -> list[dict]:
+    data = client.get_json(
+        f"/apps/{app_id}/subscriptionGroups",
+        params={"include": "subscriptions", "limit": 50},
+    )
+    groups = data.get("data", [])
+    included = {item["id"]: item for item in data.get("included", []) if item["type"] == "subscriptions"}
+    for group in groups:
+        rel_ids = [
+            item["id"]
+            for item in group.get("relationships", {})
+            .get("subscriptions", {})
+            .get("data", [])
+        ]
+        group["_subscriptions"] = [included[i] for i in rel_ids if i in included]
+    return groups
+
+
+def find_or_create_group(client: ASCClient, app_id: str, reference_name: str) -> dict:
+    for group in list_subscription_groups(client, app_id):
+        if group.get("attributes", {}).get("referenceName") == reference_name:
+            return group
+    created = client.post(
+        "subscriptionGroups",
+        {"referenceName": reference_name},
+        {"app": {"data": {"type": "apps", "id": app_id}}},
+    )
+    created["_subscriptions"] = []
+    return created
+
+
+def ensure_group_localization(
+    client: ASCClient, group_id: str, group_name: str, app_display_name: str
+) -> None:
+    data = client.get_json(f"/subscriptionGroups/{group_id}/subscriptionGroupLocalizations")
+    for loc in data.get("data", []):
+        if loc.get("attributes", {}).get("locale") == LOCALE:
+            return
+    client.post(
+        "subscriptionGroupLocalizations",
+        {"name": group_name, "locale": LOCALE, "customAppName": app_display_name},
+        {"subscriptionGroup": {"data": {"type": "subscriptionGroups", "id": group_id}}},
+    )
+
+
+def find_subscription_by_product_id(subscriptions: list[dict], product_id: str) -> dict | None:
+    for sub in subscriptions:
+        if sub.get("attributes", {}).get("productId") == product_id:
+            return sub
+    return None
+
+
+def create_subscription(
+    client: ASCClient, group_id: str, spec: dict, group_number: int
+) -> dict:
+    loc = spec["localizations"][0]
+    return client.post(
+        "subscriptions",
+        {
+            "name": spec["referenceName"],
+            "productId": spec["productID"],
+            "subscriptionPeriod": period_from_iso8601(spec["recurringSubscriptionPeriod"]),
+            "groupLevel": group_number,
+        },
+        {"group": {"data": {"type": "subscriptionGroups", "id": group_id}}},
+    )
+
+
+def ensure_subscription_localization(client: ASCClient, sub_id: str, spec: dict) -> None:
+    loc = spec["localizations"][0]
+    asc_locale = storekit_locale_to_asc(loc["locale"])
+    data = client.get_json(f"/subscriptions/{sub_id}/subscriptionLocalizations")
+    for item in data.get("data", []):
+        if item.get("attributes", {}).get("locale") == asc_locale:
+            return
+    client.post(
+        "subscriptionLocalizations",
+        {
+            "name": loc["displayName"],
+            "description": loc["description"],
+            "locale": asc_locale,
+        },
+        {"subscription": {"data": {"type": "subscriptions", "id": sub_id}}},
+    )
+
+
+def ensure_availability(client: ASCClient, sub_id: str) -> None:
+    try:
+        client.get_json(f"/subscriptionAvailabilities/{sub_id}")
+        return
+    except RuntimeError:
+        pass
+    client.post(
+        "subscriptionAvailabilities",
+        {"availableInNewTerritories": True},
+        {
+            "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
+            "availableTerritories": {"data": [{"type": "territories", "id": BASE_TERRITORY}]},
+        },
+    )
+
+
+def price_point_for_amount(client: ASCClient, sub_id: str, amount: str) -> str:
+    path = f"/subscriptions/{sub_id}/pricePoints?filter[territory]={BASE_TERRITORY}&limit=200"
+    while path:
+        data = client.get_json(path)
+        for point in data.get("data", []):
+            if point.get("attributes", {}).get("customerPrice") == amount:
+                return point["id"]
+        next_url = data.get("links", {}).get("next")
+        if not next_url:
+            break
+        path = next_url.replace(API_BASE, "")
+    raise RuntimeError(f"No {BASE_TERRITORY} price point for ${amount} on subscription {sub_id}")
+
+
+def ensure_price(client: ASCClient, sub_id: str, amount: str) -> None:
+    prices = client.get_json(f"/subscriptions/{sub_id}/prices")
+    if prices.get("meta", {}).get("paging", {}).get("total", 0) > 0:
+        return
+    price_point_id = price_point_for_amount(client, sub_id, amount)
+    client.post(
+        "subscriptionPrices",
+        {},
+        {
+            "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
+            "subscriptionPricePoint": {
+                "data": {"type": "subscriptionPricePoints", "id": price_point_id}
+            },
+        },
+    )
+
+
+def has_intro_offer(client: ASCClient, sub_id: str) -> bool:
+    data = client.get_json(f"/subscriptions/{sub_id}/introductoryOffers")
+    return data.get("meta", {}).get("paging", {}).get("total", 0) > 0
+
+
+def ensure_free_trial(client: ASCClient, sub_id: str, days: int) -> None:
+    if has_intro_offer(client, sub_id):
+        return
+    duration = {3: "THREE_DAYS"}.get(days)
+    if duration is None:
+        raise ValueError(f"Unsupported free-trial length: {days} days")
+    client.post(
+        "subscriptionIntroductoryOffers",
+        {"offerMode": "FREE_TRIAL", "duration": duration, "numberOfPeriods": 1},
+        {
+            "subscription": {"data": {"type": "subscriptions", "id": sub_id}},
+            "territory": {"data": {"type": "territories", "id": BASE_TERRITORY}},
+        },
+    )
+
+
+def intro_days_from_storekit(offer: dict | None) -> int | None:
+    if not offer:
+        return None
+    if offer.get("paymentMode") != "free":
+        return None
+    period = offer.get("subscriptionPeriod", "")
+    if period == "P3D":
+        return 3
+    return None
+
+
+def main() -> int:
+    if not ENV_PATH.exists():
+        print(f"Missing {ENV_PATH}", file=sys.stderr)
+        return 1
+    if not STOREKIT_PATH.exists():
+        print(f"Missing {STOREKIT_PATH}", file=sys.stderr)
+        return 1
+
+    env = load_env(ENV_PATH)
+    issuer = env.get("APP_STORE_CONNECT_ISSUER_ID", "")
+    key_id = env.get("APP_STORE_CONNECT_KEY_ID", "")
+    key_path = env.get("APP_STORE_CONNECT_PRIVATE_KEY_PATH", "")
+    bundle_id = env.get("APP_STORE_CONNECT_BUNDLE_ID", "")
+    if not all([issuer, key_id, key_path, bundle_id]):
+        print("Fill App Store Connect credentials in app-connect/.env", file=sys.stderr)
+        return 1
+
+    storekit = load_storekit(STOREKIT_PATH)
+    groups = storekit.get("subscriptionGroups", [])
+    if len(groups) != 1:
+        print("Expected exactly one subscription group in Paywall.storekit", file=sys.stderr)
+        return 1
+
+    sk_group = groups[0]
+    client = ASCClient(issuer, key_id, key_path)
+    app = find_app(client, bundle_id)
+    app_id = app["id"]
+    print(f"App: {app['attributes'].get('name')} ({app_id})")
+
+    asc_group = find_or_create_group(client, app_id, sk_group["name"])
+    group_id = asc_group["id"]
+    print(f"Subscription group: {sk_group['name']} ({group_id})")
+    ensure_group_localization(client, group_id, sk_group["name"], "App for Indeed")
+
+    manifest: dict = {
+        "subscriptionGroupId": group_id,
+        "subscriptionGroupName": sk_group["name"],
+        "products": [],
+    }
+
+    existing = list_subscription_groups(client, app_id)
+    current_subs: list[dict] = []
+    for group in existing:
+        if group["id"] == group_id:
+            current_subs = group.get("_subscriptions", [])
+            break
+
+    for spec in sorted(sk_group["subscriptions"], key=lambda s: s["groupNumber"]):
+        product_id = spec["productID"]
+        sub = find_subscription_by_product_id(current_subs, product_id)
+        if sub is None:
+            sub = create_subscription(client, group_id, spec, spec["groupNumber"])
+            print(f"Created subscription {product_id} ({sub['id']})")
+            current_subs.append(sub)
+        else:
+            print(f"Found subscription {product_id} ({sub['id']})")
+
+        sub_id = sub["id"]
+        ensure_subscription_localization(client, sub_id, spec)
+        ensure_availability(client, sub_id)
+        ensure_price(client, sub_id, spec["displayPrice"])
+
+        trial_days = intro_days_from_storekit(spec.get("introductoryOffer"))
+        if trial_days:
+            ensure_free_trial(client, sub_id, trial_days)
+            print(f"  Free trial: {trial_days} days")
+
+        state = sub.get("attributes", {}).get("state")
+        refreshed = client.get_json(f"/subscriptions/{sub_id}")
+        state = refreshed["data"]["attributes"].get("state", state)
+        manifest["products"].append(
+            {
+                "productId": product_id,
+                "subscriptionId": sub_id,
+                "referenceName": spec["referenceName"],
+                "displayPrice": spec["displayPrice"],
+                "period": spec["recurringSubscriptionPeriod"],
+                "state": state,
+            }
+        )
+        print(f"  State: {state}")
+
+    MANIFEST_PATH.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
+    print(f"Wrote manifest: {MANIFEST_PATH}")
+    print("Done. Review subscriptions in App Store Connect and submit for review when ready.")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())