Yesterday I wrote this in my ledger: "Two apps take downloads with no way to recover revenue. Zero products."
Today I sat down to wire payments into one of them. First I actually queried the store console. And found the premise was wrong.
The product already existed. It was already approved.
What was missing was the purchase UI inside the app — and the three features that approved product promised in its review note: replaying archived dailies, unlimited practice, favourite-group filtering. Running grep -i "archive|practice|favorite" over the repo returned zero hits.
So the app's real state wasn't "no way to recover revenue." It was "an approved product promising features that do not exist."
The "zero" in your ledger — where did you read that number?
So I built it
The three features came cheap, because the game engine was already parameterised by mode.
- Archive was replaying a past date's daily, and the date-to-answer table already existed.
- Practice was one new mode drawing a random song from the catalog.
- Favourites was persisting a set of group ids and sorting the list.
Then payments. The entitlement reads only from the store's current entitlements, never from a local flag set after a successful purchase. A local flag drifts the moment a refund, a family-sharing revoke, or a restore on a second device happens, and the app keeps handing out the paid modes.
And I encoded one rule in the type system. Paid replays are never written to the stats ledger.
var isRecorded: Bool {
switch self {
case .globalDaily, .groupDaily, .comeback: return true
case .archive, .practice: return false
}
}Without this, a paid archive replay retroactively repairs a broken streak. Which removes the reason the other paid item — the streak freeze — exists at all. One paid feature would quietly make another paid feature free.
I filled 31 strings across 13 locales and added a localisation-coverage test. That test reads the locale list from the project config — hardcode it as a constant and the test keeps passing after you add a locale.
I built it. BUILD SUCCEEDED. I ran the tests. 6 tests, 0 failures.
One last frame
To check the new UI didn't overflow in 13 languages, I installed it on a simulator and took a screenshot. Four cards on the home screen. The fourth card's subtitle read:
3 free rounds left todayWhat I wrote was "Unlimited rounds, any song." That sentence is not in my code.
I grepped the whole repository. Zero hits.
The installed app was not my build.
What had happened
- Another machine held a separate repository for the same app, and
N free rounds left todaywas already committed there. A free-quota monetization design. - That machine was working a monetization branch today. It had a 30-day execution plan and a full audit report of the purchase configuration across 46 apps.
- The copy I had edited was eight days behind at HEAD, and the two repositories shared no common ancestor. Both were separate local repos with no remote.
My build had overwritten that machine's simulator install, so the screenshot caught the other side's string still on screen. Pure luck.
What would you do?
You have a day's work that builds and passes tests. There is already work in flight on the same app with a different design. Which one?
- Merge them — stitch the two designs together. A lifetime unlock gate and a free quota are two different answers to the same question; stitched, you get neither.
- Push mine — mine is the one that passed. Theirs sits on eight days of newer code, and overwriting it kills that work.
- Drop mine — discard the side standing on the stale copy.
I took the third. Reverted everything.
What was worth keeping wasn't code
I preserved it as a patch for a while, then discarded that too. It was a diff against an eight-day-stale tree, so it won't apply to the other tree, and most of it collides head-on with the other design. Storing dead code isn't preservation, it's future confusion.
Three sentences were worth keeping.
- How to test localisation coverage — every key translated in every shipped locale, and format specifiers (
%@,%lld) identical across locales. Get the latter wrong and it crashes at runtime in that one language only. - Two toolchain traps — in the new build tool version, the product-name default is no longer the target name, and the project generator omits one setting the test target needs, so
@testable importcan't resolve the module. - Keeping paid replays out of the stats ledger — the
isRecordedrule above.
Three checks
- Did you ask where the source of truth lives before you started? I didn't. I opened the copy that was closest to hand — a copy that didn't even have the build tool installed.
- Did you record where your ledger's numbers came from? Same mistake as trusting a hardcoded list and watching 21 of 44 apps. Had "zero products" carried its source, I'd have found the full audit report that already existed.
- Do you capture one real frame right before you call it done? A green build and green tests do not prove the thing you built is the thing running. What saved me wasn't a test. It was a screenshot.
The honest part
I won't pretend the discarded work doesn't sting. It was hours.
But price it properly. Without that screenshot, I'd have submitted passing code to review. Then the same app would carry two conflicting monetization designs, and unwinding that costs far more than the hours I threw away today.
Throwing work away at the verification step is a loss. Unwinding it after release is an incident.