FolioFOLIOdocs
AppApp
BackendBackend
AssetsAssets
Backend/Subscriptions
Powered by RevenueCat — in-app subscriptions, entitlements, and cross-platform purchase management
04 / 07

Subscriptions

Getting paid

RevenueCatIAPEntitlements

Overview

Subscriptions use RevenueCat. There are two products: monthly and yearly, both gated behind a single entitlement called "GetFolio Pro". The app checks this entitlement to decide what features to show. Free users get 1 portfolio, 3 alerts, and 3 assistant messages per day. Premium unlocks unlimited everything. The useSubscriptionStore manages all of this on the client side. In development mode, isPremium always returns true so we can test premium features without buying anything.

How it works

1

On app launch, the store calls Purchases.configure() with the RevenueCat API key. This initializes the SDK and sets up a customer info listener that reacts to any subscription changes in real time.

2

After Clerk auth completes, we call Purchases.logIn(userId) to link the RevenueCat customer to the Clerk user. This is what enables subscription portability across devices. If a user signs in on a new phone, their premium status transfers over.

3

The paywall screen calls fetchOfferings() to get the available packages from RevenueCat. When a user taps "Subscribe", purchasePackage() initiates the native App Store purchase flow. RevenueCat handles the receipt validation, and the customer info listener fires with the updated entitlements.

4

Feature gating happens at three levels. The useSubscriptionStore exposes isPremium. The usePremiumFeature() hook returns isPremium plus a requiresPremium() function that navigates to the paywall. And limits are checked in each store: usePortfolioStore checks the 1 portfolio limit, useAlertsStore checks the 3 alert limit, useAssistantStore checks the 3 message per day limit.

5

Restoration works through restorePurchases() which asks RevenueCat to check the App Store for any existing entitlements tied to the user's Apple ID. This covers cases where someone reinstalls the app or gets a new device.

6

The customer center (RevenueCat's pre built UI) handles plan changes, cancellations, and billing info. We link to it from the profile screen rather than building our own subscription management interface.

Architecture Flow

1

App Launch

Purchases.configure() initializes the RevenueCat SDK with the API key. A customer info listener is registered to react to subscription changes in real time.

2

Auth Complete

After Clerk sign-in, Purchases.logIn(clerkUserId) links the RevenueCat anonymous ID to the authenticated user, enabling cross-device subscription portability.

3

Paywall Shown

Purchases.getOfferings() fetches available packages (monthly, yearly) from RevenueCat. The paywall UI renders pricing and trial info from the offering metadata.

4

Purchase Flow

Purchases.purchasePackage(pkg) triggers the native App Store/Play Store purchase sheet. RevenueCat validates the receipt server-side and returns updated customerInfo.

5

Entitlement Check

customerInfo.entitlements.active['GetFolio Pro'] is checked. If present, isPremium flips to true in the Zustand store, unlocking all gated features instantly.

6

Feature Gating

PremiumGate wrapper components check isPremium. Free users see a blurred overlay with upgrade CTA. Stores enforce limits: 1 portfolio, 3 alerts, 3 AI messages/day.

Key decisions

Entitlement based gating, not product based

We check for the "GetFolio Pro" entitlement, not for specific product IDs. This means we can change the product lineup (add a lifetime plan, adjust pricing, run promotions) without touching the app code. The entitlement is the abstraction layer between what the user bought and what features they get.

Free tier limits enforced on both client and server

The client checks limits before allowing actions (better UX, instant feedback). But the server also enforces them (prevents circumvention). For example, the alerts edge function checks the count server side before inserting. If a user somehow bypasses the client check, the server still blocks them. The client side check is for speed, the server side check is for security.

Dev mode always returns premium

During development, isPremium always returns true. This lets us test premium features without going through the purchase flow every time. The check is a simple __DEV__ ternary in the store. It means we never accidentally ship broken premium features because we could not test them locally.

Implementation

Store Initialization

The Zustand store configures RevenueCat on app launch and sets up a real-time listener for subscription changes.

typescript
// stores/useSubscriptionStore.ts
const PREMIUM_ENTITLEMENT = 'GetFolio Pro';

export const useSubscriptionStore = create<SubscriptionStore>((set, get) => ({
  isPremium: false,
  customerInfo: null,
  offerings: null,

  initialize: async () => {
    Purchases.configure({
      apiKey: process.env.EXPO_PUBLIC_REVENUECAT_API_KEY,
      appUserID: undefined, // anonymous until Clerk auth
    });

    // Real-time listener — fires on purchase, renewal, expiration
    Purchases.addCustomerInfoUpdateListener((customerInfo) => {
      const isPremium =
        customerInfo.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
      set({ customerInfo, isPremium });
    });

    await get().checkSubscription();
    await get().fetchOfferings();
  },
}));

Clerk ↔ RevenueCat User Linking

After authentication, the Clerk user ID is linked to RevenueCat so subscriptions follow the user across devices.

typescript
// Called in app/_layout.tsx after Clerk auth completes
setUserId: async (userId: string) => {
  // Links RevenueCat anonymous ID → Clerk user ID
  const { customerInfo } = await Purchases.logIn(userId);
  const isPremium =
    customerInfo.entitlements.active[PREMIUM_ENTITLEMENT] !== undefined;
  set({ customerInfo, isPremium });
},

Purchase Flow

The purchase function handles the native App Store transaction, user cancellation, and error states.

typescript
purchasePackage: async (pkg: PurchasesPackage) => {
  set({ isLoading: true, error: null });
  try {
    const { customerInfo } = await Purchases.purchasePackage(pkg);
    const isPremium =
      customerInfo.entitlements.active['GetFolio Pro'] !== undefined;
    set({ customerInfo, isPremium, isLoading: false });
    return isPremium;
  } catch (error: unknown) {
    if (error?.userCancelled) {
      set({ isLoading: false });
      return false; // user tapped cancel — not an error
    }
    set({ isLoading: false, error: error.message });
    return false;
  }
},

PremiumGate Component

A wrapper that gates any screen or feature behind a premium subscription. Shows children for premium users, an upgrade prompt for free users.

typescript
// components/PremiumGate.tsx
export function PremiumGate({ feature, children, fallback }: PremiumGateProps) {
  const { isPremium, isReady } = usePremiumFeature();
  const router = useRouter();

  if (!isReady) return null;
  if (isPremium) return <>{children}</>;
  if (fallback) return <>{fallback}</>;

  // Default upgrade prompt
  return (
    <View className="flex-1 items-center justify-center px-8">
      <Ionicons name="diamond" size={40} color="#1E54B7" />
      <Text className="text-2xl font-bold">Premium Feature</Text>
      <Text className="text-[#8E8E93] text-center">
        {feature} is available with Folio Premium.
      </Text>
      <Pressable onPress={() => router.push('/paywall')}>
        <Text className="text-white text-lg font-semibold">
          Upgrade to Premium
        </Text>
      </Pressable>
    </View>
  );
}

Dev Mode Premium Bypass

During development, premium is always true so all features can be tested without purchasing.

typescript
// Helper hook used across the app
export function usePremiumFeature() {
  const { isPremium, isInitialized } = useSubscriptionStore();

  // __DEV__ is true in development builds
  const effectivePremium = __DEV__ ? true : isPremium;

  return {
    isPremium: effectivePremium,
    isReady: __DEV__ ? true : isInitialized,
    requiresPremium: !effectivePremium && isInitialized,
  };
}

Subscription Details Hook

A convenience hook that extracts plan type, expiration date, and renewal status from RevenueCat customer info.

typescript
export function useSubscriptionDetails() {
  const { customerInfo, isPremium, getActiveSubscription } =
    useSubscriptionStore();

  const activeProduct = getActiveSubscription();
  const entitlement =
    customerInfo?.entitlements.active['GetFolio Pro'];

  return {
    isPremium,
    isYearly: activeProduct === 'yearly',
    isMonthly: activeProduct === 'monthly',
    activeProduct,
    expirationDate: entitlement?.expirationDate,
    willRenew: entitlement?.willRenew,
  };
}

Custom Paywall — Loading Offerings

The paywall fetches available packages from RevenueCat and defaults to the yearly plan.

typescript
// components/CustomPaywall.tsx
const loadOfferings = async () => {
  const offerings = await Purchases.getOfferings();
  if (offerings.current?.availablePackages) {
    const pkgs = offerings.current.availablePackages;
    setPackages(pkgs);

    // Default to yearly plan
    const yearly = pkgs.find(
      (p) => p.identifier === '$rc_annual'
        || p.identifier.includes('annual')
    );
    setSelectedPackage(yearly || pkgs[0]);
  }
};

Restore Purchases

Checks the App Store for existing entitlements tied to the user's Apple ID — covers reinstalls and new devices.

typescript
restorePurchases: async () => {
  set({ isLoading: true, error: null });
  try {
    const customerInfo = await Purchases.restorePurchases();
    const isPremium =
      customerInfo.entitlements.active['GetFolio Pro'] !== undefined;
    set({ customerInfo, isPremium, isLoading: false });
    return isPremium;
  } catch (error) {
    set({ isLoading: false, error: error.message });
    return false;
  }
},

Best Practices We Followed

Decisions informed by RevenueCat's published research and guides.

1

Onboarding paywall placement

RevenueCat's data shows 80% of trial starts happen the same day users first open an app, and onboarding accounts for roughly 50% of all trial starts. We followed this by showing a paywall during onboarding with a three-screen carousel that builds value before asking for the commitment. Users see what they get, get a reminder promise, then see the pricing — not the other way around.

The essential guide to mobile paywalls
2

7-day free trial as the default offer

RevenueCat recommends free trials as the primary conversion mechanism during onboarding because they make the upgrade feel risk-free. Our paywall leads with "Start 7-day free trial" rather than a price, and we reinforce this with a timeline showing exactly what happens on Day 1, Day 5 (reminder), and Day 7 (charge). The "No payment due now" copy reduces friction further.

Supercharge your paywalls with offers
3

Yearly plan pre-selected

RevenueCat's State of Subscription Apps report shows that annual plans have significantly higher LTV and lower churn than monthly. We default-select the yearly plan on both the onboarding paywall and the main paywall. Users can still pick monthly, but the nudge toward annual aligns with the data on long-term retention.

State of Subscription Apps 2025
4

Contextual feature gating over hard walls

Rather than blocking the entire app behind a paywall, we gate specific premium features (analytics, unlimited assets, unlimited alerts) using the PremiumGate component. Free users can explore the core experience and hit the paywall only when they try something premium. RevenueCat's research shows this contextual approach converts better than upfront hard walls because users understand the value of what they are paying for.

Contextual paywall targeting
5

Multiple paywall surfaces

We implemented three distinct paywall experiences: an onboarding paywall (first launch), a main paywall (accessible from profile and premium gates), and inline upgrade prompts (PremiumBadge, PremiumButton). RevenueCat recommends not relying on a single paywall surface — different users hit different touchpoints, and each is an opportunity to convert.

Paywall placement optimization
6

Restore purchases always accessible

Every paywall and the customer center include a "Restore purchases" button. This is an App Store requirement, but RevenueCat also highlights it as a trust signal. Users who see they can restore feel more confident purchasing because they know they will not lose access if they switch devices or reinstall.

5 overlooked paywall improvements
7

Entitlements over product IDs

RevenueCat's SDK is built around entitlements as the abstraction layer. We check for "GetFolio Pro" instead of specific product IDs. This means we can run pricing experiments, add new plans, or change trial lengths entirely from the RevenueCat dashboard without shipping an app update. The blog emphasizes this as the key to iterating on monetization quickly.

Paywalls study guide
Free1 portfolio3 alertsProUnlimitedeverything

Quick Reference

Filesstores/useSubscriptionStore.ts, app/paywall/index.tsx, app/customer-center/index.tsx
TablesN/A (RevenueCat manages subscriptions externally)
Edge FunctionsN/A
Components
useSubscriptionStoreusePremiumFeatureuseSubscriptionDetailsPremiumGatePaywallScreen
PreviousAPI LayerNextState Management