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-purchasesv9.14.0) — handles all purchase logic, entitlement management, and receipt validationZustand + 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:
RevenueCat gets initialized the moment the app mounts:
Which triggers this in the store:
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:
Paywalls — A drag-and-drop UI builder inside RevenueCat's dashboard. You design your paywall there, and the SDK renders it natively.
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_entitlementandnerd_pro_entitlementApp-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.
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:
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:
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.
