HoopSpark IAP — final review checklist

Final pre-launch review requested by Leong on 2026-09-29. 7 independent reviewers, every serious finding re-checked in the code by Tora Master. Code fixes merged and deployed 2026-09-30 (v2.827); the app part reaches phones with NOVA's next TestFlight build. Updated 2026-09-30 10:48 (UTC+8). Click a task for details.

57 tasksDone: 54Partly done: 1To do: 0Needs decision: 0Waiting: 2Not fixing: 0

Critical bugs 3 of 3 done

F4 HighSecond refund reversal gives back double Done
Problem
Refund → reversal → refund → reversal returned both clawbacks (buyer ends with 1,000 for a 500 pack).
Why it matters
Free Sparks for anyone Apple cycles twice.
Files / components
backend/internal/domain/iap/repository.go RefundReversed
Fix
Give back refund rows minus reversal rows already given back.
Dependencies
—
Acceptance
Balance is 500 after the four steps.
Tests
refund_cycle_db_test.go TestAppleSecondReversalGivesBackOnlyTheLastClawback (knifed)
Status
Done — b55add8fc (branch)
F5 HighSecond refund after a reversal fails forever Done
Problem
The second refund re-matched a partly used spend; the iap_refund_matches primary key rejected it, the whole refund rolled back, Apple kept getting 500.
Why it matters
The clawback never lands.
Files / components
repository.go reverseCreatorShares
Fix
ON CONFLICT (transaction_id, spend_id) DO UPDATE SET matched = matched + excluded.
Dependencies
—
Acceptance
Second refund succeeds; creator reversal capped at the original share.
Tests
refund_cycle_db_test.go TestSecondRefundAfterReversalMatchesSameSpendAgain (knifed)
Status
Done — b55add8fc (branch)
F9 MediumRefund-first then reversed: buyer paid, got nothing Done
Problem
A placeholder from a refund that arrived before redeem was marked credited with no owner; the later redeem got "already credited, 0".
Why it matters
A paying customer gets no Sparks.
Files / components
repository.go RefundReversed
Fix
Ownerless rows: delete the placeholder; put an unclaimed purchase back to unclaimed.
Dependencies
—
Acceptance
Redeem after refund+reversal credits 500; unclaimed stays on the admin list.
Tests
TestAppleRefundFirstThenReversedThenRedeemCredits, TestUnclaimedRefundReversedGoesBackToUnclaimed (knifed)
Status
Done — b55add8fc (branch)

Security issues 10 of 10 done

F6 LowPurchase-to-account binding set by the app Done
Problem
appAccountToken / obfuscatedAccountId are chosen by the app. A modified app can credit another user, then refund; the refund takes the same amount back from that user.
Why it matters
Corrected 2026-09-30: the victim ends where they started. Harm is confusion, tips reversed if they spent the surprise Sparks, and appearing on the refund-risk list. Nuisance, not theft.
Files / components
iap/binding.go; app/lib/services/iap_service.dart
Fix
Server issues a signed per-purchase token; the server trusts only that; fall back to the caller otherwise.
Dependencies
Leong decision A; NOVA app build
Acceptance
A forged account token no longer moves Sparks to another account.
Tests
New redeem tests with forged / valid tokens.
Status
Done — Leong 00:57 go. One-time purchase code: server a009b98ae + migration 000483, app 153aa020b, plan docs (branch). Knives: 3 server + 2 app red. Needs NOVA build after merge
F13 MediumAnyone can flood the refused-purchases list Done
Problem
Refused rows are keyed by a transaction ID read from an unverified body; 30 requests a minute per account.
Why it matters
Real "paid but not credited" rows get pushed off the 200-row admin list.
Files / components
iap/rejections.go
Fix
At most 20 rows per user; existing rows still count attempts.
Dependencies
—
Acceptance
25 fake IDs leave 20 rows; an existing row's attempts still increase.
Tests
final_review_db_test.go TestRejectionsCappedPerUser (knifed)
Status
Done — 662b626cf (branch)
F32 LowGoogle purchase token in network-error logs Done
Problem
Timeout/DNS errors carry the full URL, including the purchase token.
Why it matters
Tokens in logs.
Files / components
iap/google.go
Fix
Redact the token from the error text.
Dependencies
—
Acceptance
No full token in error logs.
Tests
No dedicated test (one-line change).
Status
Done — 662b626cf (branch)
F33 LowApple notification endpoint parses junk Done
Problem
Unauthenticated endpoint accepted 1 MB bodies and parsed any number of certificates before failing.
Why it matters
Cheap CPU exhaustion.
Files / components
iap/handler.go, verify.go
Fix
64 KB body cap; exactly 3 certificates or reject before parsing.
Dependencies
—
Acceptance
Wrong certificate counts rejected as bad JWS.
Tests
final_review_db_test.go TestJWSWithWrongCertificateCountRejectedBeforeParsing (knifed)
Status
Done — 662b626cf (branch)
F34 LowClient-reported Google price/currency unbounded Done
Problem
Display-only values came straight from the phone.
Why it matters
Garbage in the Spark history line.
Files / components
iap/google_checker.go
Fix
Currency clipped to 3 upper-case letters; out-of-range price recorded as 0.
Dependencies
—
Acceptance
Bounded values stored.
Tests
No dedicated test.
Status
Done — 662b626cf (branch)
F43 LowWelcome-gift farming with email aliases Done
Problem
Fingerprints lower-case the email only; a+1@gmail.com counts as new.
Why it matters
Extra welcome gifts (limited by the invite gate).
Files / components
wallet/grant_claims.go
Fix
Normalise plus-aliases (and Gmail dots) before fingerprinting.
Dependencies
Leong: welcome-gift task
Acceptance
Aliases share one fingerprint.
Tests
Unit test on the normaliser.
Status
Done — Live 2026-09-30 v2.830. Startup backfill checked on the live DB: 23 of 23 past gifts now have the alias fingerprint
F43.1 LowEmail normaliser Done
Problem
Aliases of one inbox look like different people.
Why it matters
Farming extra welcome gifts.
Files / components
wallet/grant_claims.go
Fix
One function: lower-case; drop +tag; Gmail/Googlemail also drops dots and treats googlemail.com as gmail.com.
Dependencies
—
Acceptance
a+1@gmail.com, a.b@gmail.com and A@googlemail.com all normalise to a@gmail.com / ab@gmail.com as expected.
Tests
Table-driven unit test.
Status
Done — 7a9c8c6ac (branch tora/iap-last4). Knives: without the +tag step or the Gmail dot step, the unit test goes red
F43.2 LowFingerprint the normalised email too Done
Problem
Only the raw lower-cased email is fingerprinted.
Why it matters
The normaliser alone changes nothing.
Files / components
wallet/grant_claims.go
Fix
Add a second email fingerprint from the normalised address, next to the old one, so existing claims keep matching.
Dependencies
F43.1
Acceptance
A new account with an alias of a claimed inbox gets 0 welcome Sparks.
Tests
DB test: claim with a@gmail.com, then a+1@gmail.com and a.@gmail.com get nothing; unrelated email still gets the gift.
Status
Done — 7a9c8c6ac (branch). Knife: without the new fingerprint, both alias tests go red
F43.3 LowBackfill the new fingerprint for past gifts Done
Problem
Accounts that already got the gift have only the old fingerprint.
Why it matters
Aliases of existing users would still slip through.
Files / components
wallet/grant_claims.go (BackfillGrantClaims)
Fix
The existing startup backfill also writes the normalised fingerprint (idempotent).
Dependencies
F43.2
Acceptance
After a restart every granted account has both fingerprints.
Tests
DB test on the backfill; live count checked after deploy.
Status
Done — 7a9c8c6ac (branch). No new code: the existing startup backfill now writes the new fingerprint too; test proves it
F42.2 LowGuard the signed date Done
Problem
Trusting the signed date lets a receipt claim any date.
Why it matters
A receipt dated in the future or outside the certificate's life must fail.
Files / components
iap/verify.go
Fix
Reject a signed date more than 5 minutes in the future, and any date outside the chain's validity.
Dependencies
F42.1
Acceptance
Future-dated or out-of-range receipts are refused.
Tests
Unit tests for both cases.
Status
Done — 75b3d33b4 + test fix (branch). Knife on the future-date guard first stayed green (another check caught it); test changed so only this guard can catch it → red

Apple configuration 1 of 2 done

F1 CriticalKey files unreadable by the server Done
Problem
The setup guide had Jeff copy the Apple and Google key files as root and chmod 600. The backend runs as user 10001, so it cannot read them.
Why it matters
Android purchases and Apple refund answers would silently stay off on launch day.
Files / components
docs/iap-setup-en.md A7.2 / B7.2 (and Chinese); backend/Dockerfile; deploy/docker-compose.prod.yml
Fix
Add chown 10001:10001 and a READABLE check command to both steps.
Dependencies
—
Acceptance
The guide's check prints READABLE; the admin page shows no red banner for the key.
Tests
Doc change. Verified on the live server that the backend runs as uid 10001 and apns.p8 is world-readable.
Status
Done — Guides fixed (f9b4433df, a81b7e20f), live
F2 HighApp Review purchases refused (sandbox) Partly done
Problem
Apple's reviewers buy in the sandbox. The server only accepts sandbox purchases from HOOP accounts in IAP_TEST_UIDS.
Why it matters
The reviewer's purchase fails and the version is rejected under guideline 2.1.
Files / components
backend/internal/domain/iap/verify.go, handler.go; docs/iap-setup-en.md A8.1
Fix
Guide step A8.1: review demo account's HOOP ID goes into IAP_TEST_UIDS before submitting; note in App Review Information.
Dependencies
Jeff: confirm or create the review account
Acceptance
Review account listed in IAP_TEST_UIDS; its sandbox purchase credits Sparks.
Tests
Part D test with that account before submission.
Status
Partly done — Guide step A8.1 live; waiting for Jeff's review account

Google configuration 1 of 1 done

F3 High"Backwards compatible" purchase option is required Done
Problem
The Android plugin (in_app_purchase_android 0.5.1) only reads the backwards-compatible purchase option. The guide said "tick it if offered".
Why it matters
Without it the app finds no product and shows "store unavailable".
Files / components
docs/iap-setup-en.md B3 / B8; ~/.pub-cache/.../google_play_product_details.dart:31-35
Fix
Make it mandatory in B3, B8 and the final checklist; stop and send a screenshot if the box is missing.
Dependencies
—
Acceptance
Every product's buy option is Active and Backwards compatible.
Tests
D2 step 2 shows the packs.
Status
Done — Guides fixed (f9b4433df), live

Backend issues 14 of 14 done

F10 MediumMissed Apple refund notifications are lost Done
Problem
Google has a daily refund sweep; Apple had none. A refunded JWS shown again was only answered "revoked".
Why it matters
A missed refund leaves Sparks in the wallet for good.
Files / components
iap/receipt.go; (new) Apple notification-history sweep
Fix
Done now: a verified revoked JWS claws back immediately. Remaining: daily App Store Server API sweep (needs the Apple key).
Dependencies
Leong decision C; Jeff's Apple In-App Purchase key
Acceptance
Refund applied even when the notification was missed.
Tests
final_review_db_test.go TestAppleRevokedJWSClawsBackWhenRefundNotificationWasMissed (knifed)
Status
Done — Revoked-JWS clawback 662b626cf + daily Apple sweep 2d57519cd (branch; runs once the Apple key is set)
F11 MediumHalf-configured Google notifications are silent Done
Problem
Android on but IAP_GOOGLE_NOTIFY_SA unset → every notification 401, no admin warning.
Why it matters
Refunds only by the daily sweep; slow payments auto-refunded after 3 days.
Files / components
server/router.go
Fix
Red admin banner google_notify + startup error.
Dependencies
—
Acceptance
Banner shows when the email is unset.
Tests
Startup wiring only; no unit test.
Status
Done — 662b626cf (branch)
F12 MediumMulti-quantity purchase via notification → 500 forever Done
Problem
The notification path returned 500 for a refused multi-quantity purchase; Pub/Sub retried for 7 days; no record.
Why it matters
Log noise, retries, no trace for support.
Files / components
iap/google_notify.go
Fix
Treat as permanent: record the refusal, answer 200.
Dependencies
—
Acceptance
200 and a refusal row.
Tests
final_review_db_test.go TestGoogleNotifyMultiQuantityIsPermanent (knifed)
Status
Done — 662b626cf (branch)
F24 LowMissing Play Console permissions only in logs Done
Problem
A key without Play permissions makes every call 403; no admin banner.
Why it matters
"Most missed step" is invisible.
Files / components
iap/google.go, router.go
Fix
Startup probe (voided list, 1 result); 401/403 → setup banner.
Dependencies
—
Acceptance
Banner when permissions are missing.
Tests
Fake Google 403 test.
Status
Done — 3d992b4af (branch): 401/403 from Google → red setup line on the admin risk page; clears on next success
F29 LowGoogle refund sweep could fail on the 30-day boundary Done
Problem
First run asked for exactly 30 days back; any 400 was logged as "token not found".
Why it matters
Daily refund sweep silently failing.
Files / components
iap/google.go, google_voided.go
Fix
29-day first window; 400 means "not found" only for token lookups.
Dependencies
—
Acceptance
Sweep succeeds on first run.
Tests
Existing sweep tests pass.
Status
Done — 662b626cf (branch)
F30 LowNotification-credited Android purchases lack a price line Done
Problem
When the notification credits first, the later redeem doesn't fill price/currency.
Why it matters
History line without price.
Files / components
iap/repository.go
Fix
Fill price/currency on an already-credited Google row with price 0.
Dependencies
—
Acceptance
Price shown.
Tests
Redeem-after-notify test.
Status
Done — 7f205ecc3 (branch): price filled once when the app redeem arrives after the notification
F31 LowNo clock leeway on Google push tokens Done
Problem
A slightly slow server clock rejects valid pushes.
Why it matters
Redelivery storms.
Files / components
iap/google_notify.go
Fix
60 s leeway.
Dependencies
—
Acceptance
Valid pushes accepted.
Tests
No dedicated test.
Status
Done — 662b626cf (branch)
F36 LowCONSUMPTION_REQUEST before redeem never answered Done
Problem
Marked answered with nothing sent if it arrives before the purchase is credited.
Why it matters
Apple decides the refund on the buyer's word.
Files / components
iap/consumption.go
Fix
Keep it pending within the 12-hour window.
Dependencies
Apple key
Acceptance
Answered once credited.
Tests
Consumption test.
Status
Done — a2d5ffa42 (branch): waits until credited, then answered
F37 LowUnclaimed Google expiry counted from order time Done
Problem
Slow payments were marked refunded before Google's 3 days from payment.
Why it matters
Admin report shows a refund that hasn't happened.
Files / components
iap/google_voided.go
Fix
Count from created_at.
Dependencies
—
Acceptance
Slow payment stays unclaimed.
Tests
TestExpireUnclaimedGoogleAfterThreeDays slow-payment row (knifed)
Status
Done — 662b626cf (branch)
F41 LowGoogle "not found" treated as permanent Done
Problem
A transient 404 right after purchase ends the purchase client-side and 200s the notification.
Why it matters
Purchase left unacknowledged → Google auto-refund (customer not charged, but lost).
Files / components
iap/google.go, google_notify.go; app iap_service.dart
Fix
Retry notifications a few times; let the app retry refused Android purchases on resume.
Dependencies
—
Acceptance
Transient 404 recovers.
Tests
Fake Google 404-then-200 test.
Status
Done — Live 2026-09-30 v2.830 (backend)
F41.1 LowRedeem: grace window for Google 'not found' Done
Problem
Google 404 on redeem is refused for good at once.
Why it matters
A purchase Google hasn't finished recording is refused (Google then refunds it).
Files / components
iap/receipt.go, rejections.go
Fix
First 15 minutes after the first try: answer 'not confirmed yet' so the app retries; after that refuse as today. Grace-window rows don't show on the admin refused list.
Dependencies
—
Acceptance
404-then-200 within the window gets credited; 404 for 15+ minutes is refused.
Tests
DB test with a fake Google that answers 404, then 200.
Status
Done — 20db78e56 (branch tora/iap-last4). Knife: grace removed → first 404 refused, test red
F41.2 LowNotifications: retry on Google 'not found' Done
Problem
A purchased notification whose token Google can't find yet is dropped.
Why it matters
That purchase then depends on the app coming back.
Files / components
iap/google_notify.go
Fix
Answer the notification with an error so Pub/Sub resends it later, instead of dropping it.
Dependencies
—
Acceptance
Notification for a 404 token is retried, credited once Google knows it.
Tests
DB test: notify 404 → 5xx; then 200 → credited.
Status
Done — 20db78e56 (branch). Knife: grace removed → notification dropped (200), test red
F42 LowApple leaf certificate checked at redeem time Done
Problem
A transaction left unfinished for a very long time could fail once the leaf expires.
Why it matters
Very rare lost credit.
Files / components
iap/verify.go
Fix
Check validity at the signed date, or accept expired leaf with a valid chain at signing time.
Dependencies
—
Acceptance
Old JWS still accepted.
Tests
Verify unit test.
Status
Done — Live 2026-09-30 v2.830 (backend)
F42.1 LowMatch Apple's own library on certificate dates Done
Problem
We check Apple's certificate at today's date.
Why it matters
Very old unfinished purchases could be refused after Apple's certificate expires.
Files / components
iap/verify.go
Fix
Apple's official library (no online revocation checks, same as us) checks the chain at the receipt's signed date. Do the same.
Dependencies
—
Acceptance
A receipt signed while the certificate was valid is accepted after it expires.
Tests
Unit test with a fake chain whose leaf has expired.
Status
Done — 75b3d33b4 (branch tora/iap-last4). Matches Apple app-store-server-library (checked in its source). Knife: chain back to today → red

Customer experience 8 of 8 done

F41.3 LowApp: 'not confirmed yet' keeps the purchase open Done
Problem
The app must not finish a purchase on the new answer.
Why it matters
Finishing would lose the retry.
Files / components
app iap_service.dart
Fix
Check the new answer maps to 'unconfirmed' (retry, don't finish); change only if it doesn't.
Dependencies
F41.1
Acceptance
App retries and never finishes on the grace answer.
Tests
Unit test on the answer mapping.
Status
Done — 20db78e56 (branch). No app code change needed; test pins not_found_yet = keep open and retry. Knife: mapping it to refused → red
F16 Medium"You're offline" shown when online Done
Problem
Server 5xx and store errors count as offline.
Why it matters
Confusing; support tickets.
Files / components
app/lib/services/iap_service.dart, util/http_retry.dart
Fix
Offline only for real connectivity errors; otherwise "store unavailable".
Dependencies
—
Acceptance
Server down shows unavailable, not offline.
Tests
Widget test.
Status
Done — 83d7a7efe (branch)
F17 MediumTop-up reachable on the web version Done
Problem
Web has no store; the page says offline forever; bindAccount throws on every web launch.
Why it matters
Broken page on web.
Files / components
app/lib/screens/account_screen.dart and other TopUpScreen.open callers; main.dart
Fix
Hide on web / say "top up in the mobile app"; skip IAP init on web.
Dependencies
—
Acceptance
No top-up entry on web; no startup exception.
Tests
Widget test with kIsWeb override.
Status
Done — 83d7a7efe (branch)
F18 MediumRestore gives no feedback Done
Problem
No spinner, no result, errors only logged; our own copy tells users to tap it.
Why it matters
Users think it's broken.
Files / components
topup_screen.dart, iap_service.dart
Fix
Busy state + snackbar with the result.
Dependencies
—
Acceptance
Restore shows checking / nothing to restore / couldn't reach store.
Tests
Widget test.
Status
Done — 83d7a7efe (branch)
F25 LowiOS finish can hang and freeze the screen Done
Problem
The plugin's finish never completes for some transactions; the grid stays locked.
Why it matters
Stuck top-up screen until restart.
Files / components
iap_service.dart
Fix
Timeout on complete(); clear in-flight before awaiting finish.
Dependencies
—
Acceptance
Screen unlocks.
Tests
Service test with a hanging fake.
Status
Done — 83d7a7efe (branch)
F26 Low"Awaiting payment" stuck after a declined request Done
Problem
No store event for a declined Ask-to-Buy / lapsed pending payment.
Why it matters
Wrong advice ("don't buy again").
Files / components
iap_service.dart, en/zh copy
Fix
Clear on resume/restore when the store no longer reports it; soften copy.
Dependencies
—
Acceptance
Tier unlocks after decline.
Tests
Service test.
Status
Done — 83d7a7efe (branch)
F27 LowAndroid refused purchase blocks its tier silently Done
Problem
itemAlreadyOwned → restore → silently skipped.
Why it matters
Tap does nothing for 3 days.
Files / components
iap_service.dart
Fix
Show the refused banner again; track account_gone too.
Dependencies
—
Acceptance
Banner shown on repeat tap.
Tests
Service test.
Status
Done — 83d7a7efe (branch)
F28 LowAndroid in-flight lock stuck on an empty event Done
Problem
Empty productID event never clears inFlight.
Why it matters
All packs disabled until restart.
Files / components
iap_service.dart
Fix
Treat empty productID as the current in-flight; clear stale in-flight on resume.
Dependencies
—
Acceptance
Packs re-enabled.
Tests
Service test.
Status
Done — 83d7a7efe (branch)

Admin functionality 7 of 7 done

F7 HighNo manual Spark adjustment tool Done
Problem
Support cannot credit a customer who paid but was refused, or undo a wrong clawback.
Why it matters
Every such ticket needs raw SQL on production.
Files / components
(new) POST /v1/admin/wallet/adjust; admin screen
Fix
Admin-only endpoint: delta + reason, ledger kind admin, audit row, idempotency key, upper limit.
Dependencies
Leong decision B
Acceptance
Adjustment appears in the customer's history and the audit log.
Tests
Endpoint + authz tests.
Status
Done — Server eb013e9a1 + app e35f492fa / 64c43e71e (branch); migration 000480
F8 HighNo customer / transaction lookup Done
Problem
No way to look up purchases by user, transaction or order ID.
Why it matters
Support can't answer tickets.
Files / components
(new) GET /v1/admin/iap/lookup; admin screen
Fix
Return transactions, refusals and recent ledger lines.
Dependencies
Leong decision B
Acceptance
Lookup by any of the three IDs.
Tests
Endpoint + authz tests.
Status
Done — Server eb013e9a1 + app e35f492fa / 64c43e71e (branch)
F14 MediumIAP risk screen incomplete Done
Problem
5 rows per list, no IDs to copy, refund_reversed list not drawn, "no risk" shown when only reversals exist.
Why it matters
Support can't act on the list.
Files / components
app/lib/widgets/admin_iap_risk.dart
Fix
Paging, copyable IDs, a refund-reversed block, fixed empty check.
Dependencies
—
Acceptance
All server lists visible and actionable.
Tests
Golden + widget tests.
Status
Done — 5f83b5915 (branch): tap a row opens the customer page, expand past 5, refunds-Apple-cancelled block, empty check fixed
F15 MediumWelcome-gift audit can't be read Done
Problem
Audit rows are written but never shown.
Why it matters
No history of who changed the gift.
Files / components
iap/admin_wallet.go; admin_welcome_grant.dart
Fix
Return and show the last N audit rows.
Dependencies
—
Acceptance
Changes listed with who and when.
Tests
Endpoint test + golden.
Status
Done — Live 2026-09-30 v2.830 (backend + web); phones with NOVA's next TF
F15.1 MediumServer: return the welcome-gift change history Done
Problem
Changes are written to an audit table but never returned.
Why it matters
Nobody can see who changed the gift.
Files / components
iap/admin_wallet.go
Fix
The welcome-gift settings answer carries the last 20 changes: who, from, to, when.
Dependencies
—
Acceptance
Two changes → both listed newest first with the admin's name.
Tests
DB test on the endpoint.
Status
Done — b4c0566a4 (branch tora/iap-last4). Knife: history left out → red
F15.2 MediumApp: show the history under the welcome gift Done
Problem
The admin card shows only the current value.
Why it matters
Same.
Files / components
app admin_welcome_grant.dart
Fix
A short 'Changes' list under the welcome-gift card (who, from → to, date); hidden when empty.
Dependencies
F15.1
Acceptance
History visible on the admin dashboard.
Tests
Widget test + golden, looked at.
Status
Done — 887cf3310 (branch). One line + See all dialog; golden looked at (first version cut off See all, reworked). Knife: line hidden → red. Dashboard tests match master
F35 LowPayout approval without an ID returned a blank 200 Done
Problem
Empty ID returned nothing; clients read it as success.
Why it matters
False "done".
Files / components
developer/developer.go
Fix
400 bad_request.
Dependencies
—
Acceptance
400 on empty ID.
Tests
payout_empty_id_db_test.go (knifed against the original code)
Status
Done — 662b626cf (branch)

Database/data 2 of 2 done

F38 LowBalance check constraint never validated Done
Problem
wallets_green_nonneg is NOT VALID.
Why it matters
A legacy negative balance would make that user's top-ups fail.
Files / components
migrations 000475
Fix
Count negatives on production; if 0, new migration VALIDATE CONSTRAINT.
Dependencies
—
Acceptance
Constraint valid.
Tests
Migration test.
Status
Done — Migration 000481 (branch); production has 0 negative balances
F39 Low000475 down migration drops financial tables Done
Problem
Unlike 000472/000474 it doesn't refuse.
Why it matters
A rollback could delete refusal/grant/refund-match history.
Files / components
migrations/000475_*.down.sql
Fix
Refuse when those tables have rows.
Dependencies
—
Acceptance
Down refuses with data.
Tests
Migration test.
Status
Done — 52bf32d2f (branch); guard verified with a test row

Documentation 6 of 6 done

F19 MediumApp Privacy / Data safety steps missing Done
Problem
Neither setup guide mentioned them.
Why it matters
Store rejection.
Files / components
docs/iap-setup-en.md A9.1 / B7.5
Fix
Added both steps and checklist rows.
Dependencies
—
Acceptance
Both declared.
Tests
—
Status
Done — f9b4433df, live
F20 MediumiPhone test expected five packs Done
Problem
TestFlight shows all six complete products.
Why it matters
A correct setup looked like a failure.
Files / components
docs/iap-setup-en.md D1 step 2
Fix
Expect six in TestFlight.
Dependencies
—
Acceptance
—
Tests
—
Status
Done — f9b4433df, live
F21 MediumTester added after buying: purchase never returns Done
Problem
Refused test purchases are finished.
Why it matters
Tester waits forever.
Files / components
Troubleshooting table
Fix
"Add the tester, then buy again."
Dependencies
—
Acceptance
—
Tests
—
Status
Done — f9b4433df, live
F22 MediumTerms don't cover Google Play or post-refund clawback Done
Problem
Terms say "iOS … Apple" only (dated 2026-08-05); nothing about taking Sparks back after a store refund.
Why it matters
Legal text doesn't match behaviour.
Files / components
deploy/www/terms.html, terms-zh.html; privacy third-party list
Fix
Draft EN/ZH lines; add Google Play to the privacy third-party list.
Dependencies
Leong decision D (legal wording)
Acceptance
Approved and published.
Tests
—
Status
Done — Leong 01:24 'merge the (D) also'. Terms + privacy (EN + ZH) live on hoopcomm.com, byte-checked against git
F23 LowPlan still showed privacy (B9) as waiting Done
Problem
Published 2026-09-29 but unticked.
Why it matters
Stale plan.
Files / components
docs/iap-en.md, iap.md
Fix
Ticked.
Dependencies
—
Acceptance
—
Tests
—
Status
Done — f9b4433df, live
F40 LowGuide gaps: restart, Google Cloud snags, step names Done
Problem
Restart doesn't re-read .env; org policy blocks key creation; permissions take up to ~24 h; env template step numbers stale.
Why it matters
Jeff guessing.
Files / components
docs/iap-setup*.md, deploy/.env.example
Fix
Fixed.
Dependencies
—
Acceptance
—
Tests
—
Status
Done — f9b4433df, live

Testing 1 of 2 done

T1 HighOn-device end-to-end tests (Part D) Waiting
Problem
Store purchases can't be tested without the consoles and real devices.
Why it matters
Only proof the full chain works.
Files / components
docs/iap-setup-en.md Part D
Fix
Run D1 and D2 after Jeff's setup and the merge.
Dependencies
Jeff's console setup; NOVA build; merge
Acceptance
D1 and D2 steps 1–8 pass.
Tests
Manual, recorded in the room.
Status
Waiting — After Jeff's setup, NOVA build and merge
T2 LowFixes without a dedicated test Done
Problem
F11 (startup wiring), F31, F32, F34 have no test of their own.
Why it matters
Regressions would go unnoticed.
Files / components
iap tests
Fix
Add small unit tests.
Dependencies
—
Acceptance
Each has a test that fails without the fix.
Tests
—
Status
Done — 64f443a48 (branch): F11, F31, F32, F34 each have a test that goes red with the fix removed

Production readiness 1 of 2 done

P1 HighMerge and deploy tora/iap-final Done
Problem
All fixes are on the branch only.
Why it matters
Nothing reaches users until merged.
Files / components
branch tora/iap-final
Fix
Merge, deploy backend, web version; NOVA build for the app parts.
Dependencies
Leong's go
Acceptance
Live server on the new build; checks pass.
Tests
Full backend + app test runs before merge.
Status
Done — Merged 09-30 01:3x (Leong 01:23 go), master 2739871f3 = v2.827. Live DB at 483 (479–483 applied, balance rule validated), backend health 200, new routes answer 401 without login, web v2.827 byte-checked. App: NOVA's next TestFlight build
P2 HighServer settings for keys and testers Waiting
Problem
IAP_TEST_UIDS, Apple key, Google key and notification SA are unset on production.
Why it matters
Test purchases refused; Android off; refund answers off.
Files / components
/root/hoop/deploy/.env
Fix
Tora sets each as Jeff reports (guide Part C).
Dependencies
Jeff's console setup
Acceptance
All C1 rows ✅; no admin banner.
Tests
Admin page check.
Status
Waiting — Needs Jeff's reports

Sources: the setup guide /iap-setup-en, the plan /iap-en, the earlier 62-item review /iap-checklist. Edit tools/iap_final_review_page.py, not this page.