diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ef9cb6b..24c2bd3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -38,6 +38,9 @@ jobs:
needs: quality
env:
APP_VARIANT: production
+ CYD_API_ENV: prod
+ IOS_DISTRIBUTION: app_store
+ APP_STORE_ANNUAL_PRODUCT_ID: premium_annual
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
ASC_API_PRIVATE_KEY_BASE64: ${{ secrets.ASC_API_PRIVATE_KEY_BASE64 }}
@@ -214,6 +217,7 @@ jobs:
needs: quality
env:
APP_VARIANT: production
+ CYD_API_ENV: prod
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
diff --git a/.github/workflows/testflight-dev.yml b/.github/workflows/testflight-dev.yml
new file mode 100644
index 0000000..a2e2b88
--- /dev/null
+++ b/.github/workflows/testflight-dev.yml
@@ -0,0 +1,205 @@
+name: Dev TestFlight
+
+on:
+ workflow_dispatch:
+ inputs:
+ app_store_annual_product_id:
+ description: App Store annual subscription product ID
+ required: true
+ default: premium_annual
+
+run-name: Dev TestFlight (${{ github.ref_name }})
+
+permissions:
+ contents: read
+
+jobs:
+ quality:
+ name: Lint and test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v5
+ with:
+ node-version: "24"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run lint
+ run: npm run lint
+
+ - name: Run tests
+ run: npm test
+
+ build_ios:
+ name: Build dev-backed iOS TestFlight
+ runs-on: macos-26-large
+ needs: quality
+ env:
+ APP_VARIANT: production
+ CYD_API_ENV: dev
+ IOS_DISTRIBUTION: app_store
+ APP_STORE_ANNUAL_PRODUCT_ID: ${{ inputs.app_store_annual_product_id }}
+ ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
+ ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
+ ASC_API_PRIVATE_KEY_BASE64: ${{ secrets.ASC_API_PRIVATE_KEY_BASE64 }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Select Xcode 26
+ run: |
+ # Pick the latest stable (non-beta) Xcode 26 on the runner.
+ # We resolve symlinks because some aliases (e.g. Xcode_26.4.0.app)
+ # don't contain "beta" in their name but point to a beta install.
+ BEST=""
+ for app in $(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V); do
+ REAL=$(readlink -f "$app" 2>/dev/null || realpath "$app")
+ if basename "$REAL" | grep -qi 'beta'; then
+ echo "Skipping $app (resolves to beta: $REAL)"
+ continue
+ fi
+ BEST="$app"
+ done
+
+ if [ -z "$BEST" ]; then
+ echo "::error::No non-beta Xcode 26 found on this runner."
+ ls -ld /Applications/Xcode*.app
+ exit 1
+ fi
+ echo "Selecting $BEST"
+ sudo xcode-select -s "$BEST/Contents/Developer"
+ xcodebuild -version
+
+ - name: Install iOS platform
+ run: xcodebuild -downloadPlatform iOS
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v5
+ with:
+ node-version: "24"
+ cache: "npm"
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Generate native projects (skip pod install)
+ run: npx expo prebuild --clean --no-install
+
+ - name: Configure Xcode Node binary
+ run: |
+ echo "NODE_BINARY=$(command -v node)" > ios/.xcode.env.local
+
+ - name: Cache CocoaPods
+ uses: actions/cache@v5
+ with:
+ path: ios/Pods
+ key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
+ restore-keys: |
+ pods-${{ runner.os }}-
+
+ - name: Install CocoaPods dependencies
+ run: cd ios && pod install
+
+ - name: Restore App Store Connect API private key
+ run: python3 ./scripts/ci/restore-asc-private-key.py
+
+ - name: Build iOS locally with Xcode
+ run: |
+ set -euo pipefail
+
+ ARCHIVE_PATH="$RUNNER_TEMP/Cyd.xcarchive"
+ EXPORT_DIR="$RUNNER_TEMP/ios-export"
+ EXPORT_OPTS="$RUNNER_TEMP/ExportOptions.plist"
+
+ cat > "$EXPORT_OPTS" <<'PLIST'
+
+
+
+
+ method
+ app-store-connect
+ signingStyle
+ automatic
+ teamID
+ G762K6CH36
+ stripSwiftSymbols
+
+ uploadSymbols
+
+ manageAppVersionAndBuildNumber
+
+
+
+ PLIST
+
+ xcodebuild \
+ -workspace ios/Cyd.xcworkspace \
+ -scheme Cyd \
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -archivePath "$ARCHIVE_PATH" \
+ -allowProvisioningUpdates \
+ -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_API_KEY_ID}.p8" \
+ -authenticationKeyID "$ASC_API_KEY_ID" \
+ -authenticationKeyIssuerID "$ASC_API_ISSUER_ID" \
+ CODE_SIGN_IDENTITY=- \
+ AD_HOC_CODE_SIGNING_ALLOWED=YES \
+ archive
+
+ xcodebuild \
+ -exportArchive \
+ -archivePath "$ARCHIVE_PATH" \
+ -exportPath "$EXPORT_DIR" \
+ -exportOptionsPlist "$EXPORT_OPTS" \
+ -allowProvisioningUpdates \
+ -authenticationKeyPath "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_API_KEY_ID}.p8" \
+ -authenticationKeyID "$ASC_API_KEY_ID" \
+ -authenticationKeyIssuerID "$ASC_API_ISSUER_ID"
+
+ IOS_BUILD_FILE=$(ls -t "$EXPORT_DIR"/*.ipa | head -1)
+ if [ -z "$IOS_BUILD_FILE" ]; then
+ echo "No .ipa file found after local iOS build"
+ exit 1
+ fi
+
+ cp "$IOS_BUILD_FILE" cyd-mobile-dev-testflight.ipa
+
+ - name: Upload iOS artifact
+ uses: actions/upload-artifact@v5
+ with:
+ name: ios-dev-testflight-ipa-${{ github.run_number }}
+ path: cyd-mobile-dev-testflight.ipa
+ if-no-files-found: error
+
+ submit_ios:
+ name: Upload dev-backed iOS build to TestFlight
+ runs-on: macos-latest
+ needs: build_ios
+ env:
+ ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
+ ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
+ ASC_API_PRIVATE_KEY_BASE64: ${{ secrets.ASC_API_PRIVATE_KEY_BASE64 }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Download iOS artifact
+ uses: actions/download-artifact@v5
+ with:
+ name: ios-dev-testflight-ipa-${{ github.run_number }}
+ path: dist/ios
+
+ - name: Restore App Store Connect API private key
+ run: python3 ./scripts/ci/restore-asc-private-key.py
+
+ - name: Submit iOS build
+ run: ./scripts/ci/submit-ios.sh dist/ios/cyd-mobile-dev-testflight.ipa
diff --git a/app.config.ts b/app.config.ts
index 742ccfc..ce0ad3a 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -6,6 +6,10 @@ export default ({ config }: ConfigContext): ExpoConfig => {
// EAS Build sets APP_VARIANT for different build profiles
// For local development, default to "development"
const isProduction = process.env.APP_VARIANT === "production";
+ const cydApiEnv = process.env.CYD_API_ENV ?? (isProduction ? "prod" : "dev");
+ const iosDistribution = process.env.IOS_DISTRIBUTION ?? "app_store";
+ const appStoreAnnualProductId =
+ process.env.APP_STORE_ANNUAL_PRODUCT_ID ?? "premium_annual";
return {
...config,
@@ -64,6 +68,7 @@ export default ({ config }: ConfigContext): ExpoConfig => {
},
plugins: [
"expo-router",
+ "expo-iap",
[
"expo-build-properties",
{
@@ -115,6 +120,9 @@ export default ({ config }: ConfigContext): ExpoConfig => {
reactCompiler: true,
},
extra: {
+ cydApiEnv,
+ iosDistribution,
+ appStoreAnnualProductId,
router: {},
eas: {
projectId: "192811c5-4578-4acb-b55d-4da9d2a5f44a",
diff --git a/components/PremiumRequiredBanner.tsx b/components/PremiumRequiredBanner.tsx
index 7d51fa7..4b178e5 100644
--- a/components/PremiumRequiredBanner.tsx
+++ b/components/PremiumRequiredBanner.tsx
@@ -21,8 +21,12 @@ type PremiumRequiredBannerProps = {
export function PremiumRequiredBanner({ palette }: PremiumRequiredBannerProps) {
const {
state: cydState,
+ premiumUpsellMode,
+ appStorePurchaseState,
getDashboardURL,
checkPremiumAccess,
+ purchasePremium,
+ restoreAppStorePurchases,
} = useCydAccount();
const [showSignInModal, setShowSignInModal] = useState(false);
const [checkingPremium, setCheckingPremium] = useState(false);
@@ -52,13 +56,31 @@ export function PremiumRequiredBanner({ palette }: PremiumRequiredBannerProps) {
} catch {
Alert.alert(
"Error",
- "Could not check your account status. Please try again."
+ "Could not check your account status. Please try again.",
);
setCheckingPremium(false);
}
})();
}, [checkPremiumAccess]);
+ const handleSubscribeWithApple = useCallback(() => {
+ void (async () => {
+ const result = await purchasePremium();
+ if (!result.success && result.error) {
+ Alert.alert("Purchase Failed", result.error);
+ }
+ })();
+ }, [purchasePremium]);
+
+ const handleRestorePurchases = useCallback(() => {
+ void (async () => {
+ const result = await restoreAppStorePurchases();
+ if (!result.success && result.error) {
+ Alert.alert("Restore Failed", result.error);
+ }
+ })();
+ }, [restoreAppStorePurchases]);
+
// Loading state while checking premium (only show if actively checking, not on initial load)
if (cydState.isLoading || checkingPremium) {
return (
@@ -145,6 +167,19 @@ export function PremiumRequiredBanner({ palette }: PremiumRequiredBannerProps) {
// User is signed in but doesn't have premium
if (cydState.hasPremiumAccess === false) {
+ const usesAppStoreIAP = premiumUpsellMode === "app_store_iap";
+ const appStorePrice = appStorePurchaseState?.product?.displayPrice;
+ const appStoreBusy = Boolean(
+ appStorePurchaseState?.isPurchasing || appStorePurchaseState?.isRestoring,
+ );
+ const appStorePrimaryLabel = appStorePurchaseState?.isPurchasing
+ ? "Purchasing…"
+ : appStorePurchaseState?.isLoadingProduct
+ ? "Loading Subscription…"
+ : appStorePrice
+ ? `Subscribe ${appStorePrice}/year`
+ : "Subscribe with Apple";
+
return (
- Deleting data requires a Premium account. Manage your account to
- upgrade to Premium.
+ {usesAppStoreIAP
+ ? "Deleting data requires a Premium account. Subscribe with your Apple ID to upgrade to Premium."
+ : "Deleting data requires a Premium account. Manage your account to upgrade to Premium."}
- [
- styles.primaryButton,
- {
- backgroundColor: palette.button?.background ?? palette.tint,
- opacity: pressed ? 0.85 : 1,
- },
- ]}
- accessibilityRole="button"
- >
-
- Manage My Account
-
-
- [
- styles.secondaryButton,
- {
- borderColor: palette.icon + "33",
- backgroundColor: palette.card,
- opacity: pressed ? 0.85 : 1,
- },
- ]}
- accessibilityRole="button"
- >
-
- I've Upgraded
-
-
+ {usesAppStoreIAP ? (
+ <>
+ [
+ styles.primaryButton,
+ {
+ backgroundColor:
+ palette.button?.background ?? palette.tint,
+ opacity:
+ pressed &&
+ !appStoreBusy &&
+ !appStorePurchaseState?.isLoadingProduct
+ ? 0.85
+ : appStoreBusy ||
+ appStorePurchaseState?.isLoadingProduct
+ ? 0.6
+ : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ {appStorePrimaryLabel}
+
+
+ [
+ styles.secondaryButton,
+ {
+ borderColor: palette.icon + "33",
+ backgroundColor: palette.card,
+ opacity:
+ pressed && !appStoreBusy
+ ? 0.85
+ : appStoreBusy
+ ? 0.6
+ : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ {appStorePurchaseState?.isRestoring
+ ? "Restoring…"
+ : "Restore Purchases"}
+
+
+ >
+ ) : (
+ <>
+ [
+ styles.primaryButton,
+ {
+ backgroundColor:
+ palette.button?.background ?? palette.tint,
+ opacity: pressed ? 0.85 : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ Manage My Account
+
+
+ [
+ styles.secondaryButton,
+ {
+ borderColor: palette.icon + "33",
+ backgroundColor: palette.card,
+ opacity: pressed ? 0.85 : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ I've Upgraded
+
+
+ >
+ )}
diff --git a/components/PremiumRequiredModal.tsx b/components/PremiumRequiredModal.tsx
index 88ee99a..c4c8608 100644
--- a/components/PremiumRequiredModal.tsx
+++ b/components/PremiumRequiredModal.tsx
@@ -30,8 +30,12 @@ export function PremiumRequiredModal({
}: PremiumRequiredModalProps) {
const {
state: cydState,
+ premiumUpsellMode,
+ appStorePurchaseState,
getDashboardURL,
checkPremiumAccess,
+ purchasePremium,
+ restoreAppStorePurchases,
} = useCydAccount();
const [showSignInModal, setShowSignInModal] = useState(false);
const [checkingPremium, setCheckingPremium] = useState(false);
@@ -73,13 +77,31 @@ export function PremiumRequiredModal({
} catch {
Alert.alert(
"Error",
- "Could not check your account status. Please try again."
+ "Could not check your account status. Please try again.",
);
setCheckingPremium(false);
}
})();
}, [checkPremiumAccess]);
+ const handleSubscribeWithApple = useCallback(() => {
+ void (async () => {
+ const result = await purchasePremium();
+ if (!result.success && result.error) {
+ Alert.alert("Purchase Failed", result.error);
+ }
+ })();
+ }, [purchasePremium]);
+
+ const handleRestorePurchases = useCallback(() => {
+ void (async () => {
+ const result = await restoreAppStorePurchases();
+ if (!result.success && result.error) {
+ Alert.alert("Restore Failed", result.error);
+ }
+ })();
+ }, [restoreAppStorePurchases]);
+
// Show alert when premium status changes after clicking "I've Upgraded"
useEffect(() => {
if (!checkingPremium && cydState.hasPremiumAccess === false) {
@@ -147,49 +169,125 @@ export function PremiumRequiredModal({
}
// User is signed in but doesn't have premium
+ const usesAppStoreIAP = premiumUpsellMode === "app_store_iap";
+ const appStorePrice = appStorePurchaseState?.product?.displayPrice;
+ const appStoreBusy = Boolean(
+ appStorePurchaseState?.isPurchasing || appStorePurchaseState?.isRestoring,
+ );
+ const appStorePrimaryLabel = appStorePurchaseState?.isPurchasing
+ ? "Purchasing…"
+ : appStorePurchaseState?.isLoadingProduct
+ ? "Loading Subscription…"
+ : appStorePrice
+ ? `Subscribe ${appStorePrice}/year`
+ : "Subscribe with Apple";
+
return (
- Deleting data requires a Premium account. Manage your account to
- upgrade to Premium.
+ {usesAppStoreIAP
+ ? "Deleting data requires a Premium account. Subscribe with your Apple ID to upgrade to Premium."
+ : "Deleting data requires a Premium account. Manage your account to upgrade to Premium."}
- [
- styles.primaryButton,
- {
- backgroundColor: palette.button?.background ?? palette.tint,
- opacity: pressed ? 0.85 : 1,
- },
- ]}
- accessibilityRole="button"
- >
-
- Manage My Account
-
-
- [
- styles.secondaryButton,
- {
- borderColor: palette.icon + "33",
- backgroundColor: palette.card,
- opacity: pressed ? 0.85 : 1,
- },
- ]}
- accessibilityRole="button"
- >
-
- I've Upgraded
-
-
+ {usesAppStoreIAP ? (
+ <>
+ [
+ styles.primaryButton,
+ {
+ backgroundColor: palette.button?.background ?? palette.tint,
+ opacity:
+ pressed &&
+ !appStoreBusy &&
+ !appStorePurchaseState?.isLoadingProduct
+ ? 0.85
+ : appStoreBusy ||
+ appStorePurchaseState?.isLoadingProduct
+ ? 0.6
+ : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ {appStorePrimaryLabel}
+
+
+ [
+ styles.secondaryButton,
+ {
+ borderColor: palette.icon + "33",
+ backgroundColor: palette.card,
+ opacity:
+ pressed && !appStoreBusy ? 0.85 : appStoreBusy ? 0.6 : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ {appStorePurchaseState?.isRestoring
+ ? "Restoring…"
+ : "Restore Purchases"}
+
+
+ >
+ ) : (
+ <>
+ [
+ styles.primaryButton,
+ {
+ backgroundColor: palette.button?.background ?? palette.tint,
+ opacity: pressed ? 0.85 : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ Manage My Account
+
+
+ [
+ styles.secondaryButton,
+ {
+ borderColor: palette.icon + "33",
+ backgroundColor: palette.card,
+ opacity: pressed ? 0.85 : 1,
+ },
+ ]}
+ accessibilityRole="button"
+ >
+
+ I've Upgraded
+
+
+ >
+ )}
);
diff --git a/components/__tests__/PremiumRequiredBanner.test.tsx b/components/__tests__/PremiumRequiredBanner.test.tsx
index 3bbbf70..31c4bab 100644
--- a/components/__tests__/PremiumRequiredBanner.test.tsx
+++ b/components/__tests__/PremiumRequiredBanner.test.tsx
@@ -27,6 +27,10 @@ const mockApiClient = {
const mockGetDashboardURL = jest.fn(() => "https://dash.cyd.social/manage");
const mockCheckPremiumAccess = jest.fn();
+const mockPurchasePremium = jest.fn(() => Promise.resolve({ success: true }));
+const mockRestoreAppStorePurchases = jest.fn(() =>
+ Promise.resolve({ success: true }),
+);
jest.mock("@/contexts/CydAccountProvider", () => ({
useCydAccount: jest.fn(() => ({
@@ -37,8 +41,20 @@ jest.mock("@/contexts/CydAccountProvider", () => ({
hasPremiumAccess: null,
},
apiClient: mockApiClient,
+ premiumUpsellMode: "external_checkout",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: null,
+ isConnected: false,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
getDashboardURL: mockGetDashboardURL,
checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
})),
}));
@@ -95,8 +111,20 @@ describe("PremiumRequiredBanner", () => {
hasPremiumAccess: null,
},
apiClient: mockApiClient,
+ premiumUpsellMode: "external_checkout",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: null,
+ isConnected: false,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
getDashboardURL: mockGetDashboardURL,
checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
});
});
@@ -127,8 +155,8 @@ describe("PremiumRequiredBanner", () => {
expect(
screen.getByText(
- "Deleting data requires a Premium account. Sign in to get started."
- )
+ "Deleting data requires a Premium account. Sign in to get started.",
+ ),
).toBeTruthy();
expect(screen.getByText("Sign In")).toBeTruthy();
expect(screen.getByTestId("cyd-avatar")).toBeTruthy();
@@ -139,8 +167,8 @@ describe("PremiumRequiredBanner", () => {
expect(
screen.getByText(
- "In the meantime, feel free to explore the delete features below."
- )
+ "In the meantime, feel free to explore the delete features below.",
+ ),
).toBeTruthy();
});
@@ -197,7 +225,7 @@ describe("PremiumRequiredBanner", () => {
// The text is split across lines, so test for partial match
expect(
- screen.getByText(/Deleting data requires a Premium account/i)
+ screen.getByText(/Deleting data requires a Premium account/i),
).toBeTruthy();
});
@@ -225,11 +253,59 @@ describe("PremiumRequiredBanner", () => {
fireEvent.press(screen.getByText("Manage My Account"));
expect(mockOpenURL).toHaveBeenCalledWith(
- "https://dash.cyd.social/manage"
+ "https://dash.cyd.social/manage",
);
mockOpenURL.mockRestore();
});
+ it("should show App Store subscribe and restore actions in App Store builds", async () => {
+ mockUseCydAccount.mockReturnValue({
+ state: {
+ isSignedIn: true,
+ userEmail: "test@example.com",
+ isLoading: false,
+ hasPremiumAccess: false,
+ },
+ apiClient: mockApiClient,
+ premiumUpsellMode: "app_store_iap",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: {
+ productId: "premium_annual",
+ title: "Cyd Premium Annual",
+ displayPrice: "$35.99",
+ },
+ isConnected: true,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
+ getDashboardURL: mockGetDashboardURL,
+ checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
+ });
+
+ render();
+
+ expect(screen.getByText("Subscribe $35.99/year")).toBeTruthy();
+ expect(screen.getByText("Restore Purchases")).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.press(screen.getByText("Subscribe $35.99/year"));
+ });
+
+ expect(mockPurchasePremium).toHaveBeenCalled();
+
+ await act(async () => {
+ fireEvent.press(screen.getByText("Restore Purchases"));
+ });
+
+ expect(mockRestoreAppStorePurchases).toHaveBeenCalled();
+ expect((Linking.openURL as jest.Mock).mock.calls).toHaveLength(0);
+ });
+
it("should check premium status when I've Upgraded is pressed", async () => {
render();
@@ -366,7 +442,7 @@ describe("PremiumRequiredBanner", () => {
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
"Error",
- "Could not check your account status. Please try again."
+ "Could not check your account status. Please try again.",
);
});
});
diff --git a/components/__tests__/PremiumRequiredModal.test.tsx b/components/__tests__/PremiumRequiredModal.test.tsx
index d6cf983..6e46103 100644
--- a/components/__tests__/PremiumRequiredModal.test.tsx
+++ b/components/__tests__/PremiumRequiredModal.test.tsx
@@ -26,6 +26,10 @@ const mockApiClient = {
const mockGetDashboardURL = jest.fn(() => "https://dash.cyd.social/manage");
const mockCheckPremiumAccess = jest.fn();
+const mockPurchasePremium = jest.fn(() => Promise.resolve({ success: true }));
+const mockRestoreAppStorePurchases = jest.fn(() =>
+ Promise.resolve({ success: true }),
+);
jest.mock("@/contexts/CydAccountProvider", () => ({
useCydAccount: jest.fn(() => ({
@@ -36,8 +40,20 @@ jest.mock("@/contexts/CydAccountProvider", () => ({
hasPremiumAccess: null,
},
apiClient: mockApiClient,
+ premiumUpsellMode: "external_checkout",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: null,
+ isConnected: false,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
getDashboardURL: mockGetDashboardURL,
checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
})),
}));
@@ -102,8 +118,20 @@ describe("PremiumRequiredModal", () => {
hasPremiumAccess: null,
},
apiClient: mockApiClient,
+ premiumUpsellMode: "external_checkout",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: null,
+ isConnected: false,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
getDashboardURL: mockGetDashboardURL,
checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
});
});
@@ -152,8 +180,8 @@ describe("PremiumRequiredModal", () => {
expect(
screen.getByText(
- "Deleting data requires a Premium account. Sign in to get started."
- )
+ "Deleting data requires a Premium account. Sign in to get started.",
+ ),
).toBeTruthy();
expect(screen.getByText("Sign In")).toBeTruthy();
});
@@ -225,7 +253,7 @@ describe("PremiumRequiredModal", () => {
// Use partial match since text spans multiple lines
expect(
- screen.getByText(/Deleting data requires a Premium account/i)
+ screen.getByText(/Deleting data requires a Premium account/i),
).toBeTruthy();
});
@@ -253,10 +281,58 @@ describe("PremiumRequiredModal", () => {
fireEvent.press(screen.getByText("Manage My Account"));
expect(mockOpenURL).toHaveBeenCalledWith(
- "https://dash.cyd.social/manage"
+ "https://dash.cyd.social/manage",
);
mockOpenURL.mockRestore();
});
+
+ it("should show App Store subscribe and restore actions in App Store builds", async () => {
+ mockUseCydAccount.mockReturnValue({
+ state: {
+ isSignedIn: true,
+ userEmail: "test@example.com",
+ isLoading: false,
+ hasPremiumAccess: false,
+ },
+ apiClient: mockApiClient,
+ premiumUpsellMode: "app_store_iap",
+ appStorePurchaseState: {
+ productId: "premium_annual",
+ product: {
+ productId: "premium_annual",
+ title: "Cyd Premium Annual",
+ displayPrice: "$35.99",
+ },
+ isConnected: true,
+ isLoadingProduct: false,
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ },
+ getDashboardURL: mockGetDashboardURL,
+ checkPremiumAccess: mockCheckPremiumAccess,
+ purchasePremium: mockPurchasePremium,
+ restoreAppStorePurchases: mockRestoreAppStorePurchases,
+ });
+
+ render();
+
+ expect(screen.getByText("Subscribe $35.99/year")).toBeTruthy();
+ expect(screen.getByText("Restore Purchases")).toBeTruthy();
+
+ await act(async () => {
+ fireEvent.press(screen.getByText("Subscribe $35.99/year"));
+ });
+
+ expect(mockPurchasePremium).toHaveBeenCalled();
+
+ await act(async () => {
+ fireEvent.press(screen.getByText("Restore Purchases"));
+ });
+
+ expect(mockRestoreAppStorePurchases).toHaveBeenCalled();
+ expect((Linking.openURL as jest.Mock).mock.calls).toHaveLength(0);
+ });
});
describe("I've Upgraded button behavior", () => {
@@ -309,7 +385,7 @@ describe("PremiumRequiredModal", () => {
+ />,
);
expect(screen.getByText("I've Upgraded")).toBeTruthy();
@@ -359,7 +435,7 @@ describe("PremiumRequiredModal", () => {
+ />,
);
await waitFor(() => {
@@ -387,7 +463,7 @@ describe("PremiumRequiredModal", () => {
+ />,
);
// User signs in and has premium
@@ -408,7 +484,7 @@ describe("PremiumRequiredModal", () => {
+ />,
);
await waitFor(() => {
@@ -465,7 +541,7 @@ describe("PremiumRequiredModal", () => {
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
"Error",
- "Could not check your account status. Please try again."
+ "Could not check your account status. Please try again.",
);
});
});
@@ -485,7 +561,7 @@ describe("PremiumRequiredModal", () => {
await waitFor(() => {
expect(Alert.alert).toHaveBeenCalledWith(
"Error",
- "Could not check your account status. Please try again."
+ "Could not check your account status. Please try again.",
);
});
});
diff --git a/constants/subscriptions.ts b/constants/subscriptions.ts
new file mode 100644
index 0000000..3609b1e
--- /dev/null
+++ b/constants/subscriptions.ts
@@ -0,0 +1,37 @@
+import Constants from "expo-constants";
+import { Platform } from "react-native";
+
+export type PremiumUpsellMode = "app_store_iap" | "external_checkout";
+
+type AppExtra = {
+ cydApiEnv?: string;
+ iosDistribution?: string;
+ appStoreAnnualProductId?: string;
+};
+
+type ConstantsWithExtra = {
+ expoConfig?: { extra?: AppExtra };
+ manifest2?: { extra?: AppExtra };
+};
+
+const constants = Constants as ConstantsWithExtra;
+const extra = constants.expoConfig?.extra ?? constants.manifest2?.extra ?? {};
+
+export type CydApiEnv = "dev" | "prod";
+
+export const CYD_API_ENV: CydApiEnv =
+ extra.cydApiEnv === "prod" || extra.cydApiEnv === "dev"
+ ? extra.cydApiEnv
+ : __DEV__
+ ? "dev"
+ : "prod";
+
+export const APP_STORE_ANNUAL_PRODUCT_ID =
+ extra.appStoreAnnualProductId ?? "premium_annual";
+
+export const IOS_DISTRIBUTION = extra.iosDistribution ?? "app_store";
+
+export const PREMIUM_UPSELL_MODE: PremiumUpsellMode =
+ Platform.OS === "ios" && IOS_DISTRIBUTION === "app_store"
+ ? "app_store_iap"
+ : "external_checkout";
diff --git a/contexts/CydAccountProvider.tsx b/contexts/CydAccountProvider.tsx
index d8c313b..563eef0 100644
--- a/contexts/CydAccountProvider.tsx
+++ b/contexts/CydAccountProvider.tsx
@@ -1,3 +1,16 @@
+import {
+ endConnection,
+ fetchProducts,
+ finishTransaction,
+ getAvailablePurchases,
+ initConnection,
+ purchaseErrorListener,
+ purchaseUpdatedListener,
+ requestPurchase,
+ restorePurchases,
+ type ProductSubscription,
+ type Purchase,
+} from "expo-iap";
import React, {
createContext,
useCallback,
@@ -8,12 +21,20 @@ import React, {
} from "react";
import { Platform } from "react-native";
+import {
+ APP_STORE_ANNUAL_PRODUCT_ID,
+ CYD_API_ENV,
+ PREMIUM_UPSELL_MODE,
+ type PremiumUpsellMode,
+} from "@/constants/subscriptions";
import {
clearCydAccountCredentials,
getCydAccountCredentials,
setCydAccountCredentials,
} from "@/database/cyd-account";
-import CydAPIClient from "@/services/cyd-api-client";
+import CydAPIClient, {
+ type SyncAppStoreSubscriptionAPIRequest,
+} from "@/services/cyd-api-client";
import { submitBlueskyProgressForAllAccounts } from "@/services/submit-bluesky-progress";
// Configuration for API environments
@@ -22,8 +43,8 @@ const PROD_DASH_URL = "https://dash.cyd.social";
const DEV_API_URL = "https://dev-api.cyd.social";
const DEV_DASH_URL = "https://dev-dash.cyd.social";
-const API_URL = __DEV__ ? DEV_API_URL : PROD_API_URL;
-const DASH_URL = __DEV__ ? DEV_DASH_URL : PROD_DASH_URL;
+const API_URL = CYD_API_ENV === "dev" ? DEV_API_URL : PROD_API_URL;
+const DASH_URL = CYD_API_ENV === "dev" ? DEV_DASH_URL : PROD_DASH_URL;
export type CydAccountState = {
isSignedIn: boolean;
@@ -32,21 +53,46 @@ export type CydAccountState = {
hasPremiumAccess: boolean | null;
};
+export type AppStoreProductSummary = {
+ productId: string;
+ title: string;
+ displayPrice: string;
+};
+
+export type AppStorePurchaseState = {
+ productId: string;
+ product: AppStoreProductSummary | null;
+ isConnected: boolean;
+ isLoadingProduct: boolean;
+ isPurchasing: boolean;
+ isRestoring: boolean;
+ error: string | null;
+};
+
+export type PremiumActionResult = {
+ success: boolean;
+ error?: string;
+};
+
export type CydAccountContextType = {
state: CydAccountState;
apiClient: CydAPIClient;
+ premiumUpsellMode: PremiumUpsellMode;
+ appStorePurchaseState: AppStorePurchaseState;
signIn: (
email: string,
verificationCode: string,
- subscribeToNewsletter: boolean
+ subscribeToNewsletter: boolean,
) => Promise<{ success: boolean; error?: string }>;
signOut: () => Promise;
sendVerificationCode: (
- email: string
+ email: string,
) => Promise<{ success: boolean; error?: string }>;
refreshState: () => Promise;
getDashboardURL: () => string;
checkPremiumAccess: () => Promise;
+ purchasePremium: () => Promise;
+ restoreAppStorePurchases: () => Promise;
};
const CydAccountContext = createContext(null);
@@ -70,6 +116,16 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
isLoading: true,
hasPremiumAccess: null,
});
+ const [appStorePurchaseState, setAppStorePurchaseState] =
+ useState({
+ productId: APP_STORE_ANNUAL_PRODUCT_ID,
+ product: null,
+ isConnected: false,
+ isLoadingProduct: PREMIUM_UPSELL_MODE === "app_store_iap",
+ isPurchasing: false,
+ isRestoring: false,
+ error: null,
+ });
const apiClient = useMemo(() => new CydAPIClient(API_URL, DASH_URL), []);
@@ -80,7 +136,7 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
if (credentials.userEmail && credentials.deviceToken) {
apiClient.setCredentials(
credentials.userEmail,
- credentials.deviceToken
+ credentials.deviceToken,
);
// Verify the session is still valid
@@ -152,14 +208,14 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
};
}
},
- [apiClient]
+ [apiClient],
);
const signIn = useCallback(
async (
email: string,
verificationCode: string,
- subscribeToNewsletter: boolean
+ subscribeToNewsletter: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
// Get device description
@@ -242,7 +298,7 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
};
}
},
- [apiClient]
+ [apiClient],
);
const signOut = useCallback(async () => {
@@ -301,31 +357,271 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
}
}, [state.isSignedIn, apiClient]);
+ const syncAppStorePurchase = useCallback(
+ async (purchase: Purchase): Promise => {
+ if (purchase.productId !== APP_STORE_ANNUAL_PRODUCT_ID) {
+ return false;
+ }
+
+ const syncRequest = getAppStoreSyncRequest(purchase);
+ if (!syncRequest) {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ error: "Apple did not return a transaction identifier.",
+ }));
+ return false;
+ }
+
+ const syncResponse =
+ await apiClient.syncAppStoreSubscription(syncRequest);
+ if ("error" in syncResponse) {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ error: syncResponse.message,
+ }));
+ return false;
+ }
+
+ await finishTransaction({ purchase, isConsumable: false });
+ setState((prev) => ({
+ ...prev,
+ hasPremiumAccess: syncResponse.premium.premium_access,
+ }));
+ setAppStorePurchaseState((prev) => ({ ...prev, error: null }));
+ return true;
+ },
+ [apiClient],
+ );
+
+ useEffect(() => {
+ if (PREMIUM_UPSELL_MODE !== "app_store_iap") {
+ return;
+ }
+
+ let isActive = true;
+ const purchaseUpdateSubscription = purchaseUpdatedListener((purchase) => {
+ void (async () => {
+ try {
+ await syncAppStorePurchase(purchase);
+ } catch (error) {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ error: getAppStoreErrorMessage(error),
+ }));
+ } finally {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isPurchasing: false,
+ }));
+ }
+ })();
+ });
+ const purchaseErrorSubscription = purchaseErrorListener((error) => {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isPurchasing: false,
+ error: isUserCancelledAppStoreError(error)
+ ? null
+ : getAppStoreErrorMessage(error),
+ }));
+ });
+
+ void (async () => {
+ try {
+ await initConnection();
+ const products = (await fetchProducts({
+ skus: [APP_STORE_ANNUAL_PRODUCT_ID],
+ type: "subs",
+ })) as ProductSubscription[];
+ const product = products.find(
+ (candidate) => candidate.id === APP_STORE_ANNUAL_PRODUCT_ID,
+ );
+ if (!isActive) {
+ return;
+ }
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ product: product ? summarizeAppStoreProduct(product) : null,
+ isConnected: true,
+ isLoadingProduct: false,
+ error: product
+ ? null
+ : "Premium is not available in the App Store yet.",
+ }));
+ } catch (error) {
+ if (!isActive) {
+ return;
+ }
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isConnected: false,
+ isLoadingProduct: false,
+ error: getAppStoreErrorMessage(error),
+ }));
+ }
+ })();
+
+ return () => {
+ isActive = false;
+ purchaseUpdateSubscription.remove();
+ purchaseErrorSubscription.remove();
+ void endConnection();
+ };
+ }, [syncAppStorePurchase]);
+
const getDashboardURL = useCallback(() => {
return apiClient.getDashboardURL();
}, [apiClient]);
+ const purchasePremium =
+ useCallback(async (): Promise => {
+ if (PREMIUM_UPSELL_MODE !== "app_store_iap") {
+ return {
+ success: false,
+ error: "Premium uses account management for this build.",
+ };
+ }
+ if (!state.isSignedIn) {
+ return { success: false, error: "Please sign in before subscribing." };
+ }
+
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isPurchasing: true,
+ error: null,
+ }));
+
+ try {
+ if (!appStorePurchaseState.isConnected) {
+ await initConnection();
+ setAppStorePurchaseState((prev) => ({ ...prev, isConnected: true }));
+ }
+
+ const subscriptionMetadata = await apiClient.getAppStoreSubscription();
+ if ("error" in subscriptionMetadata) {
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isPurchasing: false,
+ error: subscriptionMetadata.message,
+ }));
+ return { success: false, error: subscriptionMetadata.message };
+ }
+
+ await requestPurchase({
+ request: {
+ apple: {
+ sku: APP_STORE_ANNUAL_PRODUCT_ID,
+ appAccountToken: subscriptionMetadata.app_account_token,
+ },
+ },
+ type: "subs",
+ });
+ return { success: true };
+ } catch (error) {
+ const message = getAppStoreErrorMessage(error);
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isPurchasing: false,
+ error: message,
+ }));
+ return { success: false, error: message };
+ }
+ }, [apiClient, appStorePurchaseState.isConnected, state.isSignedIn]);
+
+ const restoreAppStorePurchases =
+ useCallback(async (): Promise => {
+ if (PREMIUM_UPSELL_MODE !== "app_store_iap") {
+ return {
+ success: false,
+ error: "Restore purchases is only available in the App Store build.",
+ };
+ }
+ if (!state.isSignedIn) {
+ return {
+ success: false,
+ error: "Please sign in before restoring purchases.",
+ };
+ }
+
+ setAppStorePurchaseState((prev) => ({
+ ...prev,
+ isRestoring: true,
+ error: null,
+ }));
+
+ try {
+ if (!appStorePurchaseState.isConnected) {
+ await initConnection();
+ setAppStorePurchaseState((prev) => ({ ...prev, isConnected: true }));
+ }
+
+ await restorePurchases();
+ const purchases = await getAvailablePurchases({
+ onlyIncludeActiveItemsIOS: true,
+ });
+ const premiumPurchases = purchases.filter(
+ (purchase) => purchase.productId === APP_STORE_ANNUAL_PRODUCT_ID,
+ );
+
+ if (premiumPurchases.length === 0) {
+ const message =
+ "No active Premium purchase was found for this Apple ID.";
+ setAppStorePurchaseState((prev) => ({ ...prev, error: message }));
+ return { success: false, error: message };
+ }
+
+ for (const purchase of premiumPurchases) {
+ const didSync = await syncAppStorePurchase(purchase);
+ if (didSync) {
+ return { success: true };
+ }
+ }
+
+ const message =
+ "Could not restore Premium from the purchases Apple returned.";
+ setAppStorePurchaseState((prev) => ({ ...prev, error: message }));
+ return { success: false, error: message };
+ } catch (error) {
+ const message = getAppStoreErrorMessage(error);
+ setAppStorePurchaseState((prev) => ({ ...prev, error: message }));
+ return { success: false, error: message };
+ } finally {
+ setAppStorePurchaseState((prev) => ({ ...prev, isRestoring: false }));
+ }
+ }, [
+ appStorePurchaseState.isConnected,
+ state.isSignedIn,
+ syncAppStorePurchase,
+ ]);
+
const contextValue = useMemo(
() => ({
state,
apiClient,
+ premiumUpsellMode: PREMIUM_UPSELL_MODE,
+ appStorePurchaseState,
signIn,
signOut,
sendVerificationCode,
refreshState,
getDashboardURL,
checkPremiumAccess,
+ purchasePremium,
+ restoreAppStorePurchases,
}),
[
state,
apiClient,
+ appStorePurchaseState,
signIn,
signOut,
sendVerificationCode,
refreshState,
getDashboardURL,
checkPremiumAccess,
- ]
+ purchasePremium,
+ restoreAppStorePurchases,
+ ],
);
return (
@@ -334,3 +630,52 @@ export function CydAccountProvider({ children }: CydAccountProviderProps) {
);
}
+
+function summarizeAppStoreProduct(
+ product: ProductSubscription,
+): AppStoreProductSummary {
+ return {
+ productId: product.id,
+ title: product.title,
+ displayPrice: product.displayPrice,
+ };
+}
+
+function getAppStoreSyncRequest(
+ purchase: Purchase,
+): SyncAppStoreSubscriptionAPIRequest | null {
+ if (purchase.purchaseToken) {
+ return { signed_transaction_jws: purchase.purchaseToken };
+ }
+ if ("transactionId" in purchase && purchase.transactionId) {
+ return { transaction_id: purchase.transactionId };
+ }
+ if (
+ "originalTransactionIdentifierIOS" in purchase &&
+ purchase.originalTransactionIdentifierIOS
+ ) {
+ return {
+ original_transaction_id: purchase.originalTransactionIdentifierIOS,
+ };
+ }
+ return null;
+}
+
+function isUserCancelledAppStoreError(error: unknown): boolean {
+ if (!error || typeof error !== "object") {
+ return false;
+ }
+ const code = "code" in error ? String(error.code).toLowerCase() : "";
+ const message = "message" in error ? String(error.message).toLowerCase() : "";
+ return code.includes("cancel") || message.includes("cancel");
+}
+
+function getAppStoreErrorMessage(error: unknown): string {
+ if (error && typeof error === "object" && "message" in error) {
+ return String(error.message);
+ }
+ if (typeof error === "string") {
+ return error;
+ }
+ return "Could not complete the App Store purchase. Please try again.";
+}
diff --git a/eas.json b/eas.json
index 98cd6b1..2f71873 100644
--- a/eas.json
+++ b/eas.json
@@ -8,12 +8,18 @@
"developmentClient": true,
"distribution": "internal",
"env": {
- "APP_VARIANT": "development"
+ "APP_VARIANT": "development",
+ "CYD_API_ENV": "dev",
+ "IOS_DISTRIBUTION": "app_store",
+ "APP_STORE_ANNUAL_PRODUCT_ID": "premium_annual"
}
},
"production": {
"env": {
- "APP_VARIANT": "production"
+ "APP_VARIANT": "production",
+ "CYD_API_ENV": "prod",
+ "IOS_DISTRIBUTION": "app_store",
+ "APP_STORE_ANNUAL_PRODUCT_ID": "premium_annual"
}
}
},
diff --git a/jest-setup.ts b/jest-setup.ts
index 7fb7d36..049aceb 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -104,6 +104,32 @@ jest.mock("expo-router", () => ({
Stack: "Stack",
}));
+jest.mock("expo-constants", () => ({
+ __esModule: true,
+ default: {
+ expoConfig: {
+ extra: {
+ iosDistribution: "altstore_pal",
+ appStoreAnnualProductId: "premium_annual",
+ eas: { projectId: "mock-project-id" },
+ },
+ },
+ manifest2: undefined,
+ },
+}));
+
+jest.mock("expo-iap", () => ({
+ initConnection: jest.fn(() => Promise.resolve(true)),
+ endConnection: jest.fn(() => Promise.resolve()),
+ fetchProducts: jest.fn(() => Promise.resolve([])),
+ requestPurchase: jest.fn(() => Promise.resolve()),
+ finishTransaction: jest.fn(() => Promise.resolve()),
+ restorePurchases: jest.fn(() => Promise.resolve()),
+ getAvailablePurchases: jest.fn(() => Promise.resolve([])),
+ purchaseUpdatedListener: jest.fn(() => ({ remove: jest.fn() })),
+ purchaseErrorListener: jest.fn(() => ({ remove: jest.fn() })),
+}));
+
jest.mock("react-native-safe-area-context", () => {
const React = require("react");
return {
diff --git a/package-lock.json b/package-lock.json
index 6086542..97313ca 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
"expo-document-picker": "~55.0.8",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.8",
+ "expo-iap": "^4.3.1",
"expo-image": "~55.0.6",
"expo-keep-awake": "~55.0.4",
"expo-linking": "~55.0.7",
@@ -9381,6 +9382,17 @@
"expo": "*"
}
},
+ "node_modules/expo-iap": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/expo-iap/-/expo-iap-4.3.1.tgz",
+ "integrity": "sha512-iKPP5fU3BqDWBgernSpXBWeEjEth/lteIAjykjQj3MIh3GNmI6jpers0HzjTCVihFNfBlOnIO/lb9YmnFg9ijQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-image": {
"version": "55.0.6",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-55.0.6.tgz",
diff --git a/package.json b/package.json
index ec0a622..b5478e9 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"expo-document-picker": "~55.0.8",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.8",
+ "expo-iap": "^4.3.1",
"expo-image": "~55.0.6",
"expo-keep-awake": "~55.0.4",
"expo-linking": "~55.0.7",
diff --git a/services/__tests__/cyd-api-client.test.ts b/services/__tests__/cyd-api-client.test.ts
index 6ee07b3..3ac55f3 100644
--- a/services/__tests__/cyd-api-client.test.ts
+++ b/services/__tests__/cyd-api-client.test.ts
@@ -78,7 +78,7 @@ describe("CydAPIClient", () => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "test@example.com" }),
- })
+ }),
);
});
@@ -368,6 +368,107 @@ describe("CydAPIClient", () => {
});
});
+ describe("getAppStoreSubscription", () => {
+ it("should return App Store subscription metadata on success", async () => {
+ const appStoreResponse = {
+ app_account_token: "00000000-0000-4000-8000-000000000000",
+ subscription: null,
+ premium: {
+ premium_price_cents: 1200,
+ premium_business_price_cents: 4900,
+ premium_access: false,
+ has_individual_subscription: false,
+ subscription_cancel_at_period_end: false,
+ subscription_current_period_end: "",
+ has_business_subscription: false,
+ business_organizations: [],
+ },
+ };
+
+ mockFetch.mockImplementation(async (url: string) => {
+ if (url.includes("/token")) {
+ return {
+ status: 200,
+ json: async () => ({
+ api_token: "api-token-xyz",
+ device_uuid: "device-uuid-123",
+ email: "test@example.com",
+ }),
+ };
+ }
+ if (url.includes("/user/app-store/subscription")) {
+ return {
+ status: 200,
+ json: async () => appStoreResponse,
+ };
+ }
+ return { status: 200, json: async () => ({}) };
+ });
+
+ const result = await client.getAppStoreSubscription();
+
+ expect(result).toEqual(appStoreResponse);
+ });
+ });
+
+ describe("syncAppStoreSubscription", () => {
+ it("should post signed transaction data to the App Store sync endpoint", async () => {
+ const syncResponse = {
+ subscription: {
+ original_transaction_id: "original-transaction-id",
+ latest_transaction_id: "transaction-id",
+ product_id: "premium_annual",
+ environment: "Sandbox",
+ status: "active",
+ entitlement_expires_at: "2026-06-01T00:00:00+00:00",
+ will_auto_renew: true,
+ },
+ premium: {
+ premium_price_cents: 1200,
+ premium_business_price_cents: 4900,
+ premium_access: true,
+ has_individual_subscription: true,
+ subscription_cancel_at_period_end: false,
+ subscription_current_period_end: "2026-06-01",
+ has_business_subscription: false,
+ business_organizations: [],
+ },
+ };
+
+ mockFetch.mockImplementation(
+ async (url: string, options?: RequestInit) => {
+ if (url.includes("/token")) {
+ return {
+ status: 200,
+ json: async () => ({
+ api_token: "api-token-xyz",
+ device_uuid: "device-uuid-123",
+ email: "test@example.com",
+ }),
+ };
+ }
+ if (url.includes("/user/app-store/subscription")) {
+ expect(options?.method).toBe("POST");
+ expect(options?.body).toBe(
+ JSON.stringify({ signed_transaction_jws: "transaction-jws" }),
+ );
+ return {
+ status: 200,
+ json: async () => syncResponse,
+ };
+ }
+ return { status: 200, json: async () => ({}) };
+ },
+ );
+
+ const result = await client.syncAppStoreSubscription({
+ signed_transaction_jws: "transaction-jws",
+ });
+
+ expect(result).toEqual(syncResponse);
+ });
+ });
+
describe("deleteDevice", () => {
it("should delete device successfully", async () => {
mockFetch.mockImplementation(async (url: string) => {
@@ -449,7 +550,7 @@ describe("CydAPIClient", () => {
};
}
return { status: 200, json: async () => ({}) };
- }
+ },
);
const result = await client.getDevices();
@@ -495,7 +596,7 @@ describe("CydAPIClient", () => {
const url = client.getDashboardURL();
expect(url).toBe(
- `${DASH_URL}/#/native-login/${encodeURIComponent("test@example.com")}/${encodeURIComponent("device-token-123")}/manage`
+ `${DASH_URL}/#/native-login/${encodeURIComponent("test@example.com")}/${encodeURIComponent("device-token-123")}/manage`,
);
});
});
diff --git a/services/cyd-api-client.ts b/services/cyd-api-client.ts
index daba29b..8374b87 100644
--- a/services/cyd-api-client.ts
+++ b/services/cyd-api-client.ts
@@ -61,6 +61,37 @@ export type UserPremiumAPIResponse = {
subscription_current_period_end: string;
has_business_subscription: boolean;
business_organizations: string[];
+ individual_subscription_provider?: "stripe" | "app_store" | "none";
+ individual_subscription_status?: string;
+ individual_subscription_manage_mode?: string;
+};
+
+// API models for /user/app-store/subscription
+export type AppStoreSubscriptionAPIResponse = {
+ original_transaction_id: string;
+ latest_transaction_id: string;
+ product_id: string;
+ environment: string;
+ status: string;
+ entitlement_expires_at: string | null;
+ will_auto_renew: boolean | null;
+};
+
+export type GetAppStoreSubscriptionAPIResponse = {
+ app_account_token: string;
+ subscription: AppStoreSubscriptionAPIResponse | null;
+ premium: UserPremiumAPIResponse;
+};
+
+export type SyncAppStoreSubscriptionAPIRequest = {
+ signed_transaction_jws?: string;
+ transaction_id?: string;
+ original_transaction_id?: string;
+};
+
+export type SyncAppStoreSubscriptionAPIResponse = {
+ subscription: AppStoreSubscriptionAPIResponse | null;
+ premium: UserPremiumAPIResponse;
};
// API models for POST /newsletter
@@ -161,7 +192,7 @@ export default class CydAPIClient {
private async doFetch(
method: string,
resource: string,
- body: unknown
+ body: unknown,
): Promise {
const options: RequestInit = {
method: method,
@@ -178,7 +209,7 @@ export default class CydAPIClient {
private async fetchAuthenticated(
method: string,
resource: string,
- body: unknown
+ body: unknown,
): Promise {
const options: RequestInit = {
method: method,
@@ -199,7 +230,7 @@ export default class CydAPIClient {
// Try to get a new token, and then try one more time
console.log(
- "Failed to authenticate with the server. Trying to get a new API token."
+ "Failed to authenticate with the server. Trying to get a new API token.",
);
const success = await this.getNewAPIToken();
@@ -258,68 +289,68 @@ export default class CydAPIClient {
// Auth API (not authenticated)
async authenticate(
- request: AuthAPIRequest
+ request: AuthAPIRequest,
): Promise {
console.log("POST /authenticate");
try {
const response = await this.doFetch(
"POST",
`${this.apiURL}/authenticate`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to authenticate with the server.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to authenticate with the server. Maybe the server is down?"
+ "Failed to authenticate with the server. Maybe the server is down?",
);
}
}
async registerDevice(
- request: RegisterDeviceAPIRequest
+ request: RegisterDeviceAPIRequest,
): Promise {
console.log("POST /device");
try {
const response = await this.doFetch(
"POST",
`${this.apiURL}/device`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to register device with the server.",
- response.status
+ response.status,
);
}
const data = (await response.json()) as RegisterDeviceAPIResponse;
return data;
} catch {
return this.returnError(
- "Failed to register device with the server. Maybe the server is down?"
+ "Failed to register device with the server. Maybe the server is down?",
);
}
}
async getToken(
- request: TokenAPIRequest
+ request: TokenAPIRequest,
): Promise {
console.log("POST /token");
try {
const response = await this.doFetch(
"POST",
`${this.apiURL}/token`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to get token with the server.",
- response.status
+ response.status,
);
}
const data = (await response.json()) as TokenAPIResponse;
@@ -331,7 +362,7 @@ export default class CydAPIClient {
return data;
} catch {
return this.returnError(
- "Failed to get token with the server. Maybe the server is down?"
+ "Failed to get token with the server. Maybe the server is down?",
);
}
}
@@ -339,7 +370,7 @@ export default class CydAPIClient {
// Auth API (authenticated)
async deleteDevice(
- request: DeleteDeviceAPIRequest
+ request: DeleteDeviceAPIRequest,
): Promise {
console.log("DELETE /device");
if (!(await this.validateAPIToken())) {
@@ -349,17 +380,17 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"DELETE",
`${this.apiURL}/device`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to delete device with the server.",
- response.status
+ response.status,
);
}
} catch {
return this.returnError(
- "Failed to delete device with the server. Maybe the server is down?"
+ "Failed to delete device with the server. Maybe the server is down?",
);
}
}
@@ -373,7 +404,7 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"GET",
`${this.apiURL}/device`,
- null
+ null,
);
if (response.status !== 200) {
return this.returnError("Failed to get devices.", response.status);
@@ -382,7 +413,7 @@ export default class CydAPIClient {
return { devices: data };
} catch {
return this.returnError(
- "Failed to get devices. Maybe the server is down?"
+ "Failed to get devices. Maybe the server is down?",
);
}
}
@@ -397,7 +428,7 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"GET",
`${this.apiURL}/ping`,
- null
+ null,
);
return response.status === 200;
} catch {
@@ -416,19 +447,77 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"GET",
`${this.apiURL}/user/premium`,
- null
+ null,
);
if (response.status !== 200) {
return this.returnError(
"Failed to get user premium status.",
- response.status
+ response.status,
);
}
const data = (await response.json()) as UserPremiumAPIResponse;
return data;
} catch {
return this.returnError(
- "Failed to get user premium status. Maybe the server is down?"
+ "Failed to get user premium status. Maybe the server is down?",
+ );
+ }
+ }
+
+ async getAppStoreSubscription(): Promise<
+ GetAppStoreSubscriptionAPIResponse | APIErrorResponse
+ > {
+ console.log("GET /user/app-store/subscription");
+ if (!(await this.validateAPIToken())) {
+ return this.returnError("Failed to get a new API token.");
+ }
+ try {
+ const response = await this.fetchAuthenticated(
+ "GET",
+ `${this.apiURL}/user/app-store/subscription`,
+ null,
+ );
+ if (response.status !== 200) {
+ return this.returnError(
+ "Failed to get App Store subscription metadata.",
+ response.status,
+ );
+ }
+ const data =
+ (await response.json()) as GetAppStoreSubscriptionAPIResponse;
+ return data;
+ } catch {
+ return this.returnError(
+ "Failed to get App Store subscription metadata. Maybe the server is down?",
+ );
+ }
+ }
+
+ async syncAppStoreSubscription(
+ request: SyncAppStoreSubscriptionAPIRequest,
+ ): Promise {
+ console.log("POST /user/app-store/subscription");
+ if (!(await this.validateAPIToken())) {
+ return this.returnError("Failed to get a new API token.");
+ }
+ try {
+ const response = await this.fetchAuthenticated(
+ "POST",
+ `${this.apiURL}/user/app-store/subscription`,
+ request,
+ );
+ if (response.status !== 200) {
+ return this.returnError(
+ "Failed to sync App Store subscription.",
+ response.status,
+ );
+ }
+ const data =
+ (await response.json()) as SyncAppStoreSubscriptionAPIResponse;
+ return data;
+ } catch {
+ return this.returnError(
+ "Failed to sync App Store subscription. Maybe the server is down?",
);
}
}
@@ -436,25 +525,25 @@ export default class CydAPIClient {
// Subscribe to newsletter
async postNewsletter(
- request: PostNewsletterAPIRequest
+ request: PostNewsletterAPIRequest,
): Promise {
console.log("POST /newsletter");
try {
const response = await this.doFetch(
"POST",
`${this.apiURL}/newsletter`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to subscribe to newsletter.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to subscribe to newsletter. Maybe the server is down?"
+ "Failed to subscribe to newsletter. Maybe the server is down?",
);
}
}
@@ -470,18 +559,18 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"POST",
`${this.apiURL}/user/activity`,
- null
+ null,
);
if (response.status !== 200) {
return this.returnError(
"Failed to update user activity.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to update user activity. Maybe the server is down?"
+ "Failed to update user activity. Maybe the server is down?",
);
}
}
@@ -497,7 +586,7 @@ export default class CydAPIClient {
// Submit Bluesky progress
async postBlueskyProgress(
- request: PostBlueskyProgressAPIRequest
+ request: PostBlueskyProgressAPIRequest,
): Promise {
console.log("POST /bluesky-progress");
if (!(await this.validateAPIToken())) {
@@ -506,18 +595,18 @@ export default class CydAPIClient {
const response = await this.doFetch(
"POST",
`${this.apiURL}/bluesky-progress`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to submit Bluesky progress.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to submit Bluesky progress. Maybe the server is down?"
+ "Failed to submit Bluesky progress. Maybe the server is down?",
);
}
}
@@ -526,18 +615,18 @@ export default class CydAPIClient {
const response = await this.fetchAuthenticated(
"POST",
`${this.apiURL}/bluesky-progress`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to submit Bluesky progress.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to submit Bluesky progress. Maybe the server is down?"
+ "Failed to submit Bluesky progress. Maybe the server is down?",
);
}
}
@@ -548,24 +637,24 @@ export default class CydAPIClient {
* Register a push notification token with the server
*/
async registerPushToken(
- request: RegisterPushTokenAPIRequest
+ request: RegisterPushTokenAPIRequest,
): Promise {
try {
const response = await this.fetchAuthenticated(
"POST",
`${this.apiURL}/push-notification`,
- request
+ request,
);
if (response.status !== 200 && response.status !== 201) {
return this.returnError(
"Failed to register push token.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to register push token. Maybe the server is down?"
+ "Failed to register push token. Maybe the server is down?",
);
}
}
@@ -574,24 +663,24 @@ export default class CydAPIClient {
* Update schedule settings on the server
*/
async updateScheduleSettings(
- request: UpdateScheduleSettingsAPIRequest
+ request: UpdateScheduleSettingsAPIRequest,
): Promise {
try {
const response = await this.fetchAuthenticated(
"PUT",
`${this.apiURL}/push-notification/schedule`,
- request
+ request,
);
if (response.status !== 200) {
return this.returnError(
"Failed to update schedule settings.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to update schedule settings. Maybe the server is down?"
+ "Failed to update schedule settings. Maybe the server is down?",
);
}
}
@@ -600,24 +689,24 @@ export default class CydAPIClient {
* Unregister push notifications for an account
*/
async unregisterPushToken(
- request: UnregisterPushTokenAPIRequest
+ request: UnregisterPushTokenAPIRequest,
): Promise {
try {
const response = await this.fetchAuthenticated(
"DELETE",
`${this.apiURL}/push-notification`,
- request
+ request,
);
if (response.status !== 200 && response.status !== 204) {
return this.returnError(
"Failed to unregister push token.",
- response.status
+ response.status,
);
}
return true;
} catch {
return this.returnError(
- "Failed to unregister push token. Maybe the server is down?"
+ "Failed to unregister push token. Maybe the server is down?",
);
}
}