When it comes to payment bugs, the scariest ones aren’t the bugs that throw errors.
It’s the ones that don’t.
In my app Kiroku, buying a credit pack didn’t increase the balance. The purchase sheet completed normally. The Google Play payment went through. The app showed no error. Nothing showed up in the logs. Only the balance stayed at 0.
It took a while to track down the cause, and during that time payments stayed broken in production for 11 days.
Symptoms
- Buy a credit pack → the Google Play purchase sheet completes normally
- No error shown in the app
- The
creditsfield in Firestore stays at 0 - Nothing in the Cloud Functions error logs either
My thinking was: “If there are no errors, the function must be working fine.” That assumption is what sent me the long way around.
Isolating the problem
I stopped guessing and checked facts, one at a time.
1. Check that the secret exists
Verifying purchases requires a RevenueCat Secret Key. First I checked whether it was registered in Secret Manager.
firebase functions:secrets:describe REVENUECAT_SECRET_KEY \
--project ai-diary-shipaton-744bb
What came back was 404 Not Found. It didn’t exist.
2. Look at the function’s deploy history
Next I checked when that function had been deployed.
firebase functions:log --only addPurchasedCredits
There was exactly one CreateFunction event, on August 4, 2026. After that, there was not a single UpdateFunction event. In other words, it hadn’t been redeployed even once since August 4.
3. Look inside the running revision
Looking at the serviceConfig in that response, secretEnvironmentVariables wasn’t there. The function actually running in production had no secret bound to it.
4. Compare against the spec
This is where I noticed the decisive part.
In Firebase Functions v2, if a secret referenced with defineSecret doesn’t exist in Secret Manager, the deploy itself fails.
So my current local code, which included purchase verification, had never once deployed successfully.
What had actually happened
Putting it together:
- On August 4, an old implementation without purchase verification was deployed
- After that, I wrote a new implementation with purchase verification locally
- That implementation references
REVENUECAT_SECRET_KEY - But the key didn’t exist in Secret Manager, so every deploy was failing
- Production kept running the old implementation the whole time
And that’s where the symptoms connect. The client, updated for the new implementation, sends requests shaped like {productId: 'credits_10'}. But the old implementation running in production expects a different request shape (probably the old spec, which took the amount to add directly).
The data it received didn’t match what it expected, so it added nothing and exited normally. No error. That’s why nothing ever showed up in the logs.
The scariest part wasn’t the balance not going up
Once I found the cause, I realized there was a much heavier problem.
The “old implementation” that ran in production for 11 days had no purchase verification. If the client said “give me credits,” it granted them without checking whether a purchase had been made. On top of that, the app allows anonymous authentication.
That means for 11 days, in theory, anyone could have gotten unlimited credits.
Checking whether it had been abused
This wasn’t something I could settle by guessing, so I looked at real data. I made a temporary diagnostic endpoint (deleted right after checking) and went through every document in the Firestore users collection.
The result: nothing abnormal. Here’s what that verdict was based on.
- Only 1 user had nonzero
credits - That user’s breakdown: 4 entries in the
processed_transactionssubcollection, allcredits_10 - The timestamps were 12:34–15:05, after the fixed version was deployed (12:19 JST). They matched exactly the test purchases I made myself
The deciding factor was processed_transactions. This subcollection is a concept that only exists in the new, verified implementation. The old, vulnerable implementation has no such mechanism at all.
If anyone had abused it during the vulnerable period, there would be users left with credits but an empty processed_transactions. There were zero.
So the vulnerable implementation had never once been called successfully. Because the client’s request shape had already changed to the new spec, abuse couldn’t have worked either. I got lucky.
The fix
I issued a Secret API Key in the RevenueCat dashboard, registered it, and deployed.
firebase functions:secrets:set REVENUECAT_SECRET_KEY --project <PROJECT_ID>
firebase deploy --only functions:addPurchasedCredits --project <PROJECT_ID>
By the way, this Secret Key had never been issued before. The RevenueCat dashboard has public keys (starting with appl_/goog_) and secret keys (starting with sk_), and the client SDK uses the former. Server-side purchase verification needs the latter, and it doesn’t exist unless you issue it yourself. I’d missed that.
After deploying, I made 3 purchases on a real device and confirmed the increase in all 3 places: the device logs, the RevenueCat dashboard, and the Firestore balance. Never passing something on eyeballing alone is a lesson I learned from a different bug earlier.
Two more bugs I found along the way
While chasing the cause, 2 unrelated existing bugs surfaced. Both were doing real harm.
1. Cancelling showed “purchase failed”
// This never caught anything
} on PurchasesErrorCode catch (e) {
The purchases_flutter SDK throws PlatformException, not PurchasesErrorCode. Since the types don’t match, this catch always fell through and the exception was rethrown. So even when a user simply cancelled the purchase, the app told them “Purchase failed.”
2. Credits from other sources vanished the moment you bought
The credits value the server returns is an absolute value from Firestore. But on the client, the source of truth for the balance was managed separately on the device. The old implementation did this:
_creditService.setCredits(サーバーが返した絶対値); // overwrite
(The argument here, サーバーが返した絶対値, means “the absolute value returned by the server.”)
The instant a purchase succeeded, the first-time free credits, streak bonuses, and monthly premium grants all disappeared at once. I added a field on the server that returns “the amount added this time,” and changed the client to add it instead.
Lessons
“No errors” doesn’t mean “working correctly.” It might just mean nothing is happening.
And what helped most this time was stopping the guessing and checking facts one at a time. The moment I threw out every “it should be deployed” and “the key should be set,” and looked at the actual state with describe and log, the cause appeared.
Whenever I implement anything payment-related, I run the success path on a real device and cross-check in 3 places. I’ve made that a hard rule.
This app is developed with a setup where AI agents are assigned roles. I wrote about what worked in that setup in this post. The actual cloud costs of running it are all published here.