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?", ); } }