Research Updates

Apr 15, 2026

Sonnet BioTherapeutics Holdings, Inc. Announces Stockholder Approval of Proposed Business Combination with Hyperliquid Strategies Inc

Maffs is a scientific calculator app I built with React Native and Expo. It has an AI-powered "Nerd Mode," interactive graphs, unit conversion, statistics, six custom themes, and three in-app purchase tiers. It's the kind of app that looks simple from the outside but has a lot going on under the hood.

This post isn't about how I built it — that's Part 2. This one is about the part nobody warns you about: getting an app with in-app purchases through Apple's App Review.

It took me 6 rejections and roughly two we -eks to get Maffs approved. This was my first time adding IAPs to any app I've ever shipped. I knew the first rejection was coming — I just didn't know how many more would follow.


What Is Maffs?


Maffs is a calculator app, but not the kind that ships with your phone. It's built around three tiers:

| Tier | Price | What You Get | |------|-------|--------------| | Free | $0 | Calculator, converter, graphs, 3 themes, 100-item history | | Nerd Pass | $4.99 (lifetime) | 20 AI queries/day, history search & export, precision controls, 6 themes | | Nerd Pro | $1.99/mo or $14.99/yr | Unlimited AI, conversation context, statistics, iCloud sync, graph intersections |

The core features:

  • Scientific Calculator — Full expression evaluation with sin, cos, tan, log, ln, sqrt, and more. Supports history, saved calculations, and a landscape scientific keyboard.

  • Nerd Mode — Type math in plain English. Ask "20% tip on $85" or "72°F to Celsius" and get step-by-step breakdowns. Powered by AI.

  • Unit Converter — 8 categories (length, weight, temp, volume, area, speed, time, data). Swap units with a tap.

  • Interactive Graphs — Plot up to 6 functions. Pinch to zoom, pan to explore, tap to trace coordinates, detect intersections.

  • Statistics — Input a data set, get mean, median, mode, standard deviation, variance, and range.

  • 6 Themes — Obsidian (luxury gold on black), Maffs (brand blue + yellow), Ocean, Rose, Hacker, Sunset. Each with its own typography and border radius "vibe."



The moment I decided to add paid tiers — a lifetime pass, a monthly subscription, and an annual subscription — the complexity went from "ship it" to "good luck."


The Stack

Before diving into the rejection saga, here's what powers the IAP side of Maffs:

  • RevenueCat (react-native-purchases v9.14.0) — handles all purchase logic, entitlement management, and receipt validation

  • Zustand + MMKV — local state persistence for tier, usage counts, and redeem codes

  • Expo — build toolchain, with EAS Build + Submit for CI/CD

  • App Store Connect — product configuration, agreements, and the review process itself

The architecture is straightforward:

app/_layout.tsx          calls initialize() on app start
store/subscription.ts    RevenueCat SDK config, purchase logic, tier state
app/paywall.tsx          paywall UI, fetches products & prices
plugins/withIAP.js       Expo config plugin for IAP capability
app/_layout.tsx          calls initialize() on app start
store/subscription.ts    RevenueCat SDK config, purchase logic, tier state
app/paywall.tsx          paywall UI, fetches products & prices
plugins/withIAP.js       Expo config plugin for IAP capability
app/_layout.tsx          calls initialize() on app start
store/subscription.ts    RevenueCat SDK config, purchase logic, tier state
app/paywall.tsx          paywall UI, fetches products & prices
plugins/withIAP.js       Expo config plugin for IAP capability

RevenueCat gets initialized the moment the app mounts:

// app/_layout.tsx
useEffect(() => {
  useSubscriptionStore.getState().initialize();
}, []);
// app/_layout.tsx
useEffect(() => {
  useSubscriptionStore.getState().initialize();
}, []);
// app/_layout.tsx
useEffect(() => {
  useSubscriptionStore.getState().initialize();
}, []);

Which triggers this in the store:

// store/subscription.ts
initialize: async () => {
  try {
    const apiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY ?? '';
    Purchases.setLogLevel(LOG_LEVEL.ERROR);
    Purchases.configure({ apiKey });
    await get().syncEntitlements();
  } catch (e) {
    console.warn('[RC] init failed:', e);
  } finally {
    set({ isInitialized: true });
  }
},
// store/subscription.ts
initialize: async () => {
  try {
    const apiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY ?? '';
    Purchases.setLogLevel(LOG_LEVEL.ERROR);
    Purchases.configure({ apiKey });
    await get().syncEntitlements();
  } catch (e) {
    console.warn('[RC] init failed:', e);
  } finally {
    set({ isInitialized: true });
  }
},
// store/subscription.ts
initialize: async () => {
  try {
    const apiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_KEY ?? '';
    Purchases.setLogLevel(LOG_LEVEL.ERROR);
    Purchases.configure({ apiKey });
    await get().syncEntitlements();
  } catch (e) {
    console.warn('[RC] init failed:', e);
  } finally {
    set({ isInitialized: true });
  }
},

isInitialized is intentionally set to true even on failure — so the app doesn't hang if RevenueCat is unreachable. The paywall disables purchase buttons until this flag is true.


Setting Up RevenueCat — The Initial Relief, Then the Frustration

The initial setup was fine. Create an account, link your App Store app, configure an API key. RevenueCat's docs walk you through it well enough.

Then I got to the part where I needed to actually show products to users.

RevenueCat has two approaches:

  1. Paywalls — A drag-and-drop UI builder inside RevenueCat's dashboard. You design your paywall there, and the SDK renders it natively.

  2. Offerings — You configure products and packages in RevenueCat, fetch them via the SDK, and build your own UI.

When I first saw the Paywalls feature, I was frustrated. Building a whole separate UI inside RevenueCat's dashboard, with its own design constraints, felt like it would take forever to match the look and feel of Maffs. Every theme has its own color palette, border radii, fonts — I couldn't replicate that in a drag-and-drop builder.

Then I realized I could just use Offerings — list my products in RevenueCat, fetch the data, and build my own paywall in React Native. That was the relief moment. Full control over the UI, and RevenueCat handles the backend (receipt validation, entitlement management, subscription lifecycle).

Here's how I configured it:

  • Offerings: One offering called "maffs offerings" set as the default

  • Packages: $rc_lifetime (Nerd Pass), $rc_monthly (Nerd Pro Monthly), $rc_annual (Nerd Pro Annual)

  • Entitlements: nerd_pass_entitlement and nerd_pro_entitlement

  • App-specific shared secret: Configured in RevenueCat's app settings (this one is easy to forget)


The Cache Bug That Broke Everything

This is where things got difficult. I had everything configured — products in App Store Connect, offerings in RevenueCat, entitlements mapped — but when I tried to fetch products in the app, I got nothing. Null. Empty.

I tried multiple code changes. I rewrote the fetching logic. I cleared caches, reinstalled the app, signed out and back into sandbox accounts. Nothing worked.

RevenueCat caches offerings from the moment they're first fetched. If your products weren't available at that moment — maybe the Paid Apps Agreement wasn't active yet, maybe StoreKit hadn't propagated — RevenueCat caches the empty state. And it doesn't automatically refresh the storeProduct fields even after the products become available.

I was stuck. Legitimately frustrated. I couldn't display prices, couldn't trigger purchases, couldn't test anything.


The StoreKit Configuration Breakthrough

That's when I created a local StoreKit Configuration file. You can create one in Xcode, sync it from App Store Connect, and point your Xcode scheme to it. This lets you test IAPs in the simulator with local product definitions.

Xcode Edit Scheme Run Options StoreKit Configuration Select your .storekit file
Xcode Edit Scheme Run Options StoreKit Configuration Select your .storekit file
Xcode Edit Scheme Run Options StoreKit Configuration Select your .storekit file

The moment I did this, products started appearing locally. I could see prices, trigger purchase sheets, validate the flow. It wasn't production data — it was local testing data — but it proved my code was correct. The issue was entirely on the RevenueCat/StoreKit propagation side.

That gave me the confidence to push forward. I knew my code worked. I just needed the real products to propagate.


The Workaround

For production, I built a dual-path purchase flow:

// store/subscription.ts
purchase: async (productId: string) => {
  if (!get().isInitialized) {
    throw new Error('Store not ready — please wait a moment and try again');
  }
  // Try offerings first, fall back to direct StoreKit purchase
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages.find(
    (p) => p.storeProduct?.productIdentifier === productId,
  );
  if (pkg) {
    await Purchases.purchasePackage(pkg);
  } else {
    // RevenueCat cache stale — fetch directly from StoreKit
    const products = await Purchases.getProducts([productId]);
    const product = products.find((p) => p.identifier === productId);
    if (!product) throw new Error('Product not available');
    await Purchases.purchaseStoreProduct(product);
  }
  await get().syncEntitlements();
},
// store/subscription.ts
purchase: async (productId: string) => {
  if (!get().isInitialized) {
    throw new Error('Store not ready — please wait a moment and try again');
  }
  // Try offerings first, fall back to direct StoreKit purchase
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages.find(
    (p) => p.storeProduct?.productIdentifier === productId,
  );
  if (pkg) {
    await Purchases.purchasePackage(pkg);
  } else {
    // RevenueCat cache stale — fetch directly from StoreKit
    const products = await Purchases.getProducts([productId]);
    const product = products.find((p) => p.identifier === productId);
    if (!product) throw new Error('Product not available');
    await Purchases.purchaseStoreProduct(product);
  }
  await get().syncEntitlements();
},
// store/subscription.ts
purchase: async (productId: string) => {
  if (!get().isInitialized) {
    throw new Error('Store not ready — please wait a moment and try again');
  }
  // Try offerings first, fall back to direct StoreKit purchase
  const offerings = await Purchases.getOfferings();
  const pkg = offerings.current?.availablePackages.find(
    (p) => p.storeProduct?.productIdentifier === productId,
  );
  if (pkg) {
    await Purchases.purchasePackage(pkg);
  } else {
    // RevenueCat cache stale — fetch directly from StoreKit
    const products = await Purchases.getProducts([productId]);
    const product = products.find((p) => p.identifier === productId);
    if (!product) throw new Error('Product not available');
    await Purchases.purchaseStoreProduct(product);
  }
  await get().syncEntitlements();
},

Try offerings first (which includes trial info and package metadata). If the storeProduct is null because of the cache issue, fall back to Purchases.getProducts() — which hits StoreKit directly and bypasses the offerings cache.

The paywall also fetches prices directly instead of relying on offerings:

// app/paywall.tsx
useEffect(() => {
  Purchases.getProducts([
    PRODUCT_IDS.NERD_PASS,
    PRODUCT_IDS.NERD_PRO_MONTHLY,
    PRODUCT_IDS.NERD_PRO_ANNUAL,
  ])
    .then(setProducts)
    .catch(() => {}); // fall back to hardcoded prices
}, []);
 
const getPrice = (productId: string, fallback: string) =>
  products.find((p) => p.identifier === productId)?.priceString ?? fallback;
// app/paywall.tsx
useEffect(() => {
  Purchases.getProducts([
    PRODUCT_IDS.NERD_PASS,
    PRODUCT_IDS.NERD_PRO_MONTHLY,
    PRODUCT_IDS.NERD_PRO_ANNUAL,
  ])
    .then(setProducts)
    .catch(() => {}); // fall back to hardcoded prices
}, []);
 
const getPrice = (productId: string, fallback: string) =>
  products.find((p) => p.identifier === productId)?.priceString ?? fallback;
// app/paywall.tsx
useEffect(() => {
  Purchases.getProducts([
    PRODUCT_IDS.NERD_PASS,
    PRODUCT_IDS.NERD_PRO_MONTHLY,
    PRODUCT_IDS.NERD_PRO_ANNUAL,
  ])
    .then(setProducts)
    .catch(() => {}); // fall back to hardcoded prices
}, []);
 
const getPrice = (productId: string, fallback: string) =>
  products.find((p) => p.identifier === productId)?.priceString ?? fallback;

If even the direct fetch fails, the UI shows hardcoded fallback prices ("$4.99", "$1.99/mo", "$14.99/yr"). The user always sees something.


© 2026 Guidant BioTherapeutics, Inc. All Rights Reserved.

Privacy Policy

Disclaimer