← 返回所有文章
此文章尚未提供中文版本,正在顯示英文版本。

How we built Takt’s monetization

Takt runs entirely on your device. I don’t run a server and there’s no new account. Your data syncs through your own iCloud if at all. I never see it and I don’t want to. I can’t personally justify charging a subscription for something that costs me very little to maintain. My initial thought was cheap app, but then my friend Micha and I sparred and ended at an inbetween; good free version and lifetime ‘paid app’ and a subscription that unlocks lifetime after 12x subs. The months don’t have to be consecutive of course. This felt much better.

How the gate works

The entire premium check is one computed property:

var isPremium: Bool {
    return hasLifetime || isSubscribed
}

hasLifetime is true if they bought lifetime directly or earned it through the loyalty path. isSubscribed is true if there’s an active monthly subscription. Everything in the app checks this single boolean - activity limits, tag limits, all of it. There’s no reason to scatter permission logic around when one property covers every case.

The free tier still includes all features (Live Activity, widgets, Apple Watch, exports). The only constraint is 3 activities and 5 tags, which honestly covers most people.

The loyalty check

This is where StoreKit 2 does the heavy lifting. Transaction.all gives you an async sequence of every transaction the user has ever made with your app. Each transaction has a revocationDate that’s nil unless it was refunded.

So the loyalty counter is just a loop:

var monthCount = 0

for await result in Transaction.all {
    guard let transaction = try? result.payloadValue else { continue }

    if transaction.productID == "dk.philipkartin.takt.monthly"
       && transaction.revocationDate == nil {
        monthCount += 1
    }
}

if monthCount >= 12 {
    hasLifetime = true
}

Apple maintains the transaction history. I just count it. If someone gets a refund, that transaction’s revocationDate gets set and it stops counting. If refunds drop them below 12, the loyalty lifetime gets revoked. It works in both directions without any special handling.

No receipt validation server, no webhooks. The whole thing runs client-side.

Startup and caching

On launch I load cached entitlement state from UserDefaults so the UI doesn’t flash between free and premium while StoreKit warms up. Then I refresh from StoreKit in the background. A Transaction.updates listener runs for the app’s lifetime to catch renewals and refunds as they happen:

func startTransactionListener() {
    transactionListener = Task {
        for await result in Transaction.updates {
            if let transaction = try? result.payloadValue {
                await transaction.finish()
                await refreshEntitlements()
            }
        }
    }
}

The refreshEntitlements() call runs checkCurrentEntitlements() (active subscription? purchased lifetime?), then countAccumulatedMonths(), then evaluateLoyaltyLifetime().

The edges

When someone hits 12 months for the first time, I show a celebration screen. It tells them Takt is theirs and suggests they cancel the subscription since they don’t need it anymore. The screen only shows once.

If a subscription lapses before they reach 12, nothing gets deleted. They see a screen where they pick which 3 activities to keep active. Their month count stays where it is, so if they come back later, they pick up from where they left off.

And the evaluateLoyaltyLifetime() function handles the edge case where someone earns loyalty lifetime and then later refunds enough months to drop below 12 - it revokes the loyalty flag, but keeps any directly purchased lifetime intact. They’re tracked separately.

Feel free to share if you’re building something like this :-)

The technical implementation is genuinely simple. StoreKit 2’s Transaction.all is the entire foundation and it works without a server. Convincing yourself that giving away lifetime access for long-term subscribers is worth it. It is.

Takt on the App Store