Every week, someone leaves a one-star review on one of our apps saying the same thing: "I would pay for this, but I will not subscribe." Not "the app is bad." Not "the features are missing." Just: I refuse to rent software.
For a while, I treated these reviews as noise. Subscriptions are the default monetization model on the App Store. Apple encourages them. RevenueCat is built around them. Every indie dev blog says recurring revenue is the only sustainable path.
But the reviews kept coming. And they were not from cheapskates — they were from people who explicitly said they would pay a one-time price. So I ran an experiment: what if we gave them exactly that?
The Paid-Analogue Strategy
The idea is simple. For each free app that monetizes through subscriptions, we ship a separate paid app that unlocks all features for a one-time purchase. Same codebase, same features, different business model.
Take eXpense, our expense tracker. The free version has a 50-item limit. Beyond that, you subscribe monthly or yearly. Some users love the trial-then-subscribe flow. Others see the subscription prompt and immediately leave.
So we built Ledgr Finance. Same expense tracking engine, same UI, same localization across 30+ languages. But no subscription. You pay once, you own it. No paywall view, no "upgrade" banner, no trial countdown.
We did the same across the portfolio:
- Email Converter (free + subscription) → MailShift (one-time purchase)
- Mbox Splitter Pro → MailSplit
- Phygital Timer → Tempus Time
- Wattora → Voltara
- PARA Mail → MailFlow
Each pair shares the same underlying project. The paid version is not a fork — it is a compile-time variant of the same code.
How It Works: One Codebase, Two Targets
The mechanism is a Swift active compilation condition called PAID_VERSION. Each app's project.yml (we use XcodeGen for all our projects) defines two targets: the free app and its paid analogue. The paid target adds one extra build setting:
targets:
eXpense:
type: application
platform: iOS
sources: [Sources]
dependencies:
- package: AppFoundation
Ledgr:
type: application
platform: iOS
sources: [Sources]
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: PAID_VERSION
dependencies:
- package: AppFoundation
Both targets compile the same Sources directory. The only difference is that one preprocessor flag.
What the Flag Controls
The PAID_VERSION flag gates three things: the paywall UI, the RevenueCat SDK import, and the feature-limit logic.
1. The paywall disappears entirely. This is not a matter of hiding a button. The entire PaywallView file is wrapped in a compile guard:
#if !PAID_VERSION
import RevenueCat
import RevenueCatUI
struct AppPaywallView: View {
@State private var isLoading = true
@State private var hasError = false
var body: some View {
// Full paywall with loading states,
// timeout handling, retry logic
}
}
#else
import SwiftUI
struct AppPaywallView: View {
var body: some View { EmptyView() }
}
#endif
When PAID_VERSION is active, the RevenueCat SDK is never imported. It is not just unused — it is not compiled into the binary at all. The paid app has no subscription framework, no purchase flow, no receipt validation. This is intentional. A user who paid upfront should never see a trace of subscription infrastructure.
2. Feature limits are removed. The free version of eXpense limits users to 50 expenses before requiring a subscription. In the paid version:
var isFeatureUnlocked: Bool {
#if PAID_VERSION
return true
#else
return premiumManager.hasActiveSubscription
#endif
}
Every feature gate in the app resolves to true at compile time. The optimizer eliminates the dead branches entirely — no runtime overhead, no conditional checks.
3. The onboarding changes. The free app's onboarding includes a screen explaining the subscription model. The paid app skips it. Same mechanism: a compile guard around the subscription-explanation page in the onboarding sequence.
Why Not Just Add a One-Time Purchase IAP?
The obvious question: why ship a separate app instead of adding a "lifetime unlock" in-app purchase to the free version?
I tried that first. It does not work as well as you would expect, for three reasons.
App Store search fragmentation. Users searching for "expense tracker no subscription" will never find your app if it is listed as a free app with in-app purchases. The App Store categorizes it as freemium. A separate paid app appears in searches for "paid expense tracker" and "one-time purchase expense app" — entirely different keyword surfaces.
Review sentiment separation. When a free app has a lifetime IAP, the one-star "I hate subscriptions" reviews still land on the free app's listing. They dilute the rating. With a separate paid app, the subscription-averse users go directly to the paid listing. Their reviews are positive because they got exactly what they wanted. The free app's reviews improve too, because the most vocal critics now have an alternative.
Pricing clarity. A paid app with a single price is the clearest possible value proposition. No "which plan do I pick?" decision. No trial that expires. No mental accounting about monthly costs. The price is on the App Store page. You pay it. Done.
The XcodeGen Workflow
Managing two targets per app sounds like it would double the maintenance burden. In practice, XcodeGen absorbs most of it.
Each app has a single project.yml that defines both targets. Running xcodegen generate produces a fresh .xcodeproj with both targets configured. The targets share everything: source files, assets, localization files, test targets. The only differences are the bundle identifier, the display name, the app icon, and the PAID_VERSION flag.
# Shared settings at project level
settings:
base:
DEVELOPMENT_TEAM: "YOUR_TEAM_ID"
MARKETING_VERSION: "1.4.0"
targets:
eXpense:
# ... free target config
Ledgr:
# ... paid target config
settings:
base:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: PAID_VERSION
PRODUCT_BUNDLE_IDENTIFIER: com.techconcepts.ledgr
PRODUCT_NAME: Ledgr
A critical detail: the PAID_VERSION flag must be set on the target level, not at the project level. Setting it at the project level applies it to all targets — including test targets. This means your paywall tests, which verify that the subscription flow works correctly, would silently pass because they compile with PAID_VERSION active and never execute the subscription code paths.
What About AppFoundation?
Our shared Swift Package, AppFoundation, handles the compile flag transparently. When the paid target builds, AppFoundation detects PAID_VERSION and adjusts its behavior:
- AppSetup.configure() skips RevenueCat initialization entirely in paid builds. No API key is sent, no network call is made, no customer ID is created.
- PremiumManager always returns
truefor entitlement checks. The premium state is a compile-time constant, not a runtime query. - Analytics and crash reporting still initialize normally. Paid users deserve the same stability monitoring.
This means the app-level code does not need to know which variant it is building. The compile flag propagates through the package dependency, and each module adjusts accordingly.
The Hard Parts
Two App Store listings per app. Each paid analogue needs its own screenshots, descriptions, keywords, and privacy policy. With 38 languages per app, this doubles the metadata maintenance. We mitigate this with batch scripts that push localized descriptions across all variants simultaneously, but it is still real work.
Icon differentiation. The paid app needs a visually distinct icon so users can tell them apart on their home screen. We use the same base design with a different accent color or a subtle badge. Enough to distinguish, not so different that it looks like a different product.
Pricing decisions. What should the one-time price be? Too low, and it cannibalizes subscription revenue. Too high, and subscription-averse users balk. We generally price the paid version at roughly the same cost as one year of the subscription. A user who would have subscribed for two years pays less with the one-time purchase. A user who would never have subscribed pays something instead of nothing.
App Store review. Apple reviews the paid app as a separate submission. If the free version gets approved but the paid version triggers a review issue (often around screenshots or metadata), you are managing two review cycles in parallel. Our batch submission pipeline handles this, but it adds complexity.
Does It Work?
The short answer: yes. Not every paid analogue justifies its existence, but the strategy overall adds revenue that would not exist otherwise. These are purchases from users who would never have subscribed. They are not cannibalized subscription customers — they are net new.
The longer answer is that the cost of maintaining paid analogues is surprisingly low when the architecture is right. Adding a new paid variant to an existing app takes about an hour: create the target in project.yml, set the bundle ID, design an icon variant, and push the metadata. The code is already there. The localization is already there. The tests are already there.
Key Takeaways
- Subscription-averse users are not cheapskates. They actively want to pay — just not monthly. Giving them a path converts revenue that otherwise does not exist.
PAID_VERSIONcompile guards are the cleanest way to create free/paid variants. The subscription SDK is not just hidden — it is not compiled.- Set the flag on the target, not the project. Project-level flags leak into test targets and silently suppress subscription test coverage.
- XcodeGen makes dual targets manageable. One
project.yml, two targets, shared sources. No Xcode project file drift. - Separate App Store listings capture different search intents. "Free expense tracker" and "paid expense tracker one-time" are different keyword surfaces.
- Price at roughly one year of subscription. Fair to both user segments, does not cannibalize.
The paid-analogue approach is not revolutionary. It is a straightforward recognition that different users have different preferences about how they pay for software. The technical work to support both models from one codebase is minimal. The business upside is a new revenue stream from users you were previously ignoring.