We ran an adversarial pass over one of our own codebases — not a feature review, not a test run, but a deliberate attempt to find the paths where the code does the wrong thing quietly. Every finding was verified against the actual code before anything was changed.
The worst one charged people for the wrong product.
The subscription that fell back
The purchase flow looked up the product the user had tapped inside the current offering. If it was not there, it fell back to availablePackages[0] — the first package in the list.
Read that again in terms of what a user experiences. They tap Annual. If the annual product is not in the offering the SDK currently has loaded — because of a configuration change, a caching lag, a partially propagated dashboard edit — they get charged for whatever happens to be first in the array. Which, in our case, was monthly.
The user is charged the wrong amount for the wrong term. Apple’s guideline 3.1.1 requires that the purchase match what was presented. It is also, in the most direct sense, a refund generator: every one of those is a support ticket, a chargeback, or a one-star review that mentions billing.
The fallback exists because someone, reasonably, did not want the purchase to fail. That instinct is the bug. A purchase that fails is recoverable in a way that a purchase of the wrong thing is not. The user tries again, or contacts you, and nothing has been taken.
The fix searches every offering rather than only the current one, and if the product genuinely is not found, it errors. Failing loudly is the correct behaviour when money is involved.

Charged, and told it failed
The same service had a second money defect in the opposite direction.
When the purchase SDK returned an error, the app declared the purchase failed and revoked the user’s local Pro flag. That sounds right. It is not, because RevenueCat can return customer information before the transaction has settled — this is particularly common on Android.
So the sequence was: the user is charged, the SDK returns pre-settlement state, the app concludes failure, and the user is shown “Purchase failed” while their Pro access is removed. They have paid and lost the thing they paid for, and the app has told them the payment did not go through.
The fix is to reconcile with our own backend before declaring failure. The SDK’s immediate response is a useful signal and not a verdict. Anything involving money needs a second source before you tell a user what happened to theirs.
The reward that never paid
A different screen offered gold for watching an advertisement. It checked the result like this:
if (res?.rewarded) { grantGold() }
The function it was calling resolves with { granted }.
Not rewarded. granted. The property being checked did not exist, so it was always undefined, so the branch never ran. Every advertisement watched from that screen paid out nothing.
No error. No crash. No log line. The ad played, the user watched it to completion in exchange for a reward, and the reward silently did not arrive. From the user’s side this is indistinguishable from the app cheating them.
This is the same class as a historical bug in the same codebase where a restore flow checked res.pro against an object that returns res.isPro. One renamed field, one silently disabled feature, and TypeScript will not save you when the object is typed loosely or comes from a third-party SDK.
If you take one practical thing from this post: go and check the field names your ad and purchase callbacks actually resolve with, against what your code reads. It takes ten minutes and it is one of the highest-yield checks in a mobile codebase, because the failure is completely silent and it costs you money directly.


Gold that vanished when someone tapped twice
Two of the findings were races, and both had the same shape.
The balance functions read the current value from storage, added or subtracted, and wrote it back. A plain read-modify-write, which is correct exactly as long as only one of them runs at a time.
They do not. A user double-taps a claim button. A game win and a daily bonus resolve in the same instant. Two win callbacks fire from the same round. Each reads the same starting balance, each writes back its own result, and one of the two updates is simply gone.
The user does not report this, because they cannot see it. They know roughly what their balance was and they are not auditing it. It looks like an accounting error they half-remember, and it is one.
Balance changes now serialise through a promise queue, so they queue rather than interleave.
The second race was worse. The token refresh had no de-duplication for in-flight calls, so two parallel 401 responses would both try to use a rotating refresh token. The first one rotated it. The second presented a token that was now invalid, got a 401, and the error handling deleted both tokens. The user is silently logged out, mid-session, for no reason they can perceive.
Any read-modify-write against shared state in a mobile app is a race waiting for a fast user. Storage is shared state. So is an auth token.

And one that just assumed
The authentication check returned true when it could not decode the token — on the reasoning, written in the code, that it should “assume valid”.
So a corrupted storage string, or a deliberately crafted one, passed the auth gate. The check now denies on decode failure.
This is the whole lesson of the exercise in one line. When a security or money check cannot determine the answer, the safe default is no, and the comfortable default is yes. Every one of these defects was somebody choosing the comfortable default for a good-sounding reason.
Why a normal test pass finds none of this
Every one of these findings is invisible on the happy path.
The subscription works when the product is in the current offering, which it is, in testing. The purchase succeeds when the SDK settles promptly, which it does, on a good connection. The ad reward — well, that one never worked at all, and still nobody noticed, because nobody had checked their balance before and after with a stopwatch.
The races need two things to happen at once, and a careful tester does one thing at a time. That is what makes them careful, and it is exactly why they will not find this.
Finding these requires a different question. Not does this work but what happens if this arrives twice, out of order, or not at all. That is a separate pass with a separate mindset, and it is worth budgeting for explicitly, because it does not happen as a side effect of ordinary QA.
If you are shipping an app with subscriptions, rewarded ads or any kind of in-app currency, those are the three places to look first. They are where the silent failures cost real money — and where a five-star app quietly becomes a billing complaint.
We do this kind of pass as part of ongoing app maintenance and support, and it is built into how we approach iOS and Android development from the start.
Awesome Apps builds and maintains iOS and Android applications for Australian businesses.

![]()
Talk to a Sydney app developer — free.
30 minutes. We'll tell you what your app needs, how long it takes, and what it costs. Real answers, no sales pitch.
Book Free App Strategy Call →Free · 30 minutes · No obligation