| Finding | Severity | Area | What | Status |
|---|
| ☑ | S2-1 / S12-1 | Critical | Migrations | The three IAP migrations are numbered 000466–000468, but production is already at 000471. The branch also lacks master's 000469–000471. | fixed · e8d6aef3f (tora/iap-fixes): migrations renumbered 000472–000474, master merged in |
| ☑ | S3-1 | High | Apple verification | Receipts and notifications are accepted if their certificate chain reaches Apple Root CA G3 with any key usage. The App Store certificate-type OIDs (leaf 1.2.840.113635.100.6.11.1, intermediate …6.2.1) are never checked. Apple's own verification library checks them. | fixed · e5ef792a8 (tora/iap-fixes): App Store certificate marks required; forged purchase + refund notice tests |
| ☑ | S3-2 / S5-3 / S7-5 | High | Refunds (both stores) | A refund / void for a purchase we have not credited yet is dropped with a 200 and nothing stored. If the purchase is credited afterwards (old signed receipt, or a credit racing the refund), it is never clawed back. | fixed · 41066eb8c (tora/iap-fixes): refunded placeholder; later credit → 400 revoked |
| ☑ | S5-2 | High | Apple refunds | When Refund() fails (database error, deploy restart), the Apple notification handler still answers 200. | fixed · 41066eb8c (tora/iap-fixes): failed Apple clawback answers 500 so Apple retries |
| ☑ | S1-3 / S6-1 | High | Google validation | The Spark amount for an Android purchase is looked up from the product id the phone sends. Google's returned productId is never compared with it; the fake Google server ignores the product too. | fixed · fc175b249 (tora/iap-fixes): Google productId must match |
| ☑ | S6-2 / S7-2 | High | Google acknowledge (G7/G8) | The backend's acknowledge to Google runs in an in-memory goroutine (now, 5 s, 30 s, 2 min) and nothing about it is stored. | fixed · 79ce554ae (tora/iap-fixes): acked_at stored; retried from the DB every 15 min for 3 days |
| ☑ | S7-1 | High | Google voided backstop (G5) | The daily voided-purchases poll always looks back 72 hours, keeps no record of the last successful run, and reads only the first page. | fixed · 79ce554ae (tora/iap-fixes): voided poll resumes from its saved cursor (≤30 days) |
| ☑ | S8-1 | High | Payouts — also live on master today | Settling a payout reads the pending row without a lock and updates it without checking it is still pending. | fixed · 403386609 on master, live 2026-09-29 08:27: row lock + status check on settle |
| ☑ | S10-1 | High | App (Android) | The app never asks Google for unfinished purchases at start-up; only the manual Restore button does. | fixed · a03e57ab1 (tora/iap-fixes): unfinished purchases asked for after login and on resume (Android + iOS) |
| ☑ | S2-3 / S5-1 | High | Deleted accounts (B5, R5, D4) | HOOP never deletes a users row (deletion blanks the fields), so 'user_id set to NULL' on deletion never happens. The B5 test hard-deletes a user, which production never does. | fixed · 4d9a52445 (tora/iap-fixes): deleted = soft-deleted; refund takes normal clawback, loss flagged account_deleted; test models production |
| ☑ | S5-5 | High | Apple consumption reply (R3) | R3 calls Apple's V1 Send Consumption Information endpoint with V1 fields. | fixed · 52b5453c2 (tora/iap-fixes): Apple V2 consumption API and fields |
| ☑ | S5-7 / S12-5 | High | Privacy (R3 vs B9) | R3 always tells Apple customerConsented = true, and switches on as soon as the App Store key is installed. The setup guide already asks Jeff to create that key; the privacy-policy line (B9) is only drafted. | fixed · 52b5453c2 (tora/iap-fixes): IAP_REFUND_CONSENT_LIVE switch, off until B9 is published |
| ☐ | S12-3 | High | Setup guide | The guide tells Jeff to copy the Apple and Google key files to /root/hoop/deploy/, but the backend container only mounts deploy/secrets as /secrets. | open |
| ☐ | S12-4 | High | Setup guide (Play permissions) | The guide says to grant the service account only 'View financial data' because 'the app consumes purchases itself'. Since 09-28 the backend acknowledges purchases (G7/G8), which (to be confirmed on Google's permission table) needs 'Manage orders'. | open |
| ☐ | S1-1 / S12-8 | High | Product IDs (B1 / P0.1) | B1 kept com.hooptech.hoop.green.* as a 'P0.1 default'. The spec has no default: the ID set depends on what Jeff finds in App Store Connect (P0.1), and §1.2.1 lists spark.*. | open |
| ☑ | S10-6 / S11-1 | High | App (both) | The purchase service keeps its per-account state (balance, banners, retries) across logout and account switch, and the top-up screen keeps the first balance it saw (??=). | fixed · a03e57ab1 (tora/iap-fixes): per-account state reset on account change; balance read applies unless a credit landed meanwhile |
| ☑ | S11-2 | High | App translations | Six admin strings hard-code 'MYR' (en + zh), which the existing translation-currency guard test forbids. | fixed · f66babefb (tora/iap-fixes): currency out of the admin strings; translation_currency_test green |
| ☑ | S2-2 / S12-2 | High | Registry & progress notes | §9's progress note, WIP.md and LEDGER all say the branch 'waits for 464/465 to reach production' — those numbers were voided and will never exist. The branch's LEDGER pointer (000469) also conflicts with master's (000472). | fixed · LEDGER c400f5c1b; WIP 4883a75b4; §9 progress note rewritten 5a3f2b017 (master) + d1c88da45 (branch) |
| ☑ | S3-3 / S6-3 | Medium | Tester list (B7) | The test-purchase tester check looks at the logged-in caller, not the account that receives the Sparks (the Google notification path checks the owner instead). | fixed · fc175b249 (tora/iap-fixes): credited account must be a tester too |
| ☑ | S6-4 | Medium | Google validation | Only purchaseState 1 and 2 are refused; any other or missing value is credited. | fixed · fc175b249 (tora/iap-fixes): only purchaseState 0 credits |
| ☑ | S6-5 | Medium | Google config | If the credentials file is set but can't be loaded, the server still starts with Android purchases off (one log line). | fixed · 33cd705a4 backend + 6e9c1376b admin banner (tora/iap-fixes): unreadable store credentials shown in red on the money-risk page |
| ☑ | S7-3 | Medium | Google refunds | Partial (quantity) refunds are not handled: the void notification claws back the whole purchase; the poll never sees partial refunds. | fixed · f11d15623 (tora/iap-fixes): Google multi-quantity refused (no ack → Google refunds; recorded); keep it off in Play Console |
| ☑ | S5-4 | Medium | Refund attribution (R1) | Spends with no creator share (AI assistant, self-purchase, shares rounded to 0) are matched again by every later refund. | fixed · 41b246a23 (tora/iap-fixes): each refund's spend matches stored (iap_refund_matches); a spend is matched once in total |
| ☑ | S5-6 | Medium | Apple consumption reply (R3) | The reply is sent once, in a goroutine, with no retry or persistence (Apple does not re-ask after we answered 200). | fixed · 52b5453c2 (tora/iap-fixes): requests stored and retried every 10 min within 12 h |
| ☑ | S5-8 | Medium | Apple notifications | Environment is never checked; the bundle only if present; inner and outer payloads are not compared. | fixed · 55f029940 (tora/iap-fixes): notification bundle required on both layers; inner/outer environment must match |
| ☑ | S5-9 | Medium | Apple notifications | REFUND_REVERSED is not handled. | fixed · eb60076f7 + 0dd94ab36 (tora/iap-fixes): Leong 09-29 — buyer's clawed Sparks given back automatically; creator shares listed for a person |
| ☑ | S8-2 | Medium | Payout review (F1) | The free-funded share is computed over earnings since the previous payout up to now, not the earnings inside this payout. | fixed · 7bbe9c5a3 (tora/iap-fixes): share covers only the earnings inside this payout (by release date); stored at settle |
| ☑ | S8-3 | Medium | Payout review (F1) | A payer who deletes their account counts as 0% free (their wallet ledger cascades away). | fixed · 7bbe9c5a3 (tora/iap-fixes): payer with shares but no spend history counts as 100% free |
| ☑ | S8-4 | Medium | Bad-debt list (R5) | Deleted-refund rows show the full amount even when the refund lost nothing; short refunds are double-counted; unclaimed-then-refunded rows count as loss. | fixed · 4d9a52445 (tora/iap-fixes): loss list uses unattributed only, no duplicate list, refunded-unclaimed not loss, totals over all rows |
| ☑ | S8-6 | Medium | Payout review (F1) | Any non-empty note (e.g. a bank reference) satisfies reason_required; approver and share at approval are not stored. | fixed · 7bbe9c5a3 (tora/iap-fixes): separate reason (note = bank ref no longer passes); approver + share stored |
| ☑ | S4-4 | Medium | Welcome grant | Deleting an account and signing up again with the same email / phone creates a new user and a new welcome grant. | fixed · 35182316b (tora/iap-fixes): Leong 09-29 once per email/phone forever — HMAC fingerprints in welcome_grant_claims; needs IDENTITY_FINGERPRINT_KEY |
| ☑ | S4-5 | Medium | Welcome grant guard (W1) | The 'no Go file defines the grant amount' guard misses grouped const/var blocks (including ledger.go's own), locals and SQL; it only runs with a database. | fixed · ad113ada1 (tora/iap-fixes): guard rewritten on go/ast, DB-free, proves itself on bad samples |
| ☑ | S10-2 | Medium | App | The purchase listener starts only at a cold start while logged in, or when the top-up screen opens — not right after login. | fixed · a03e57ab1 (tora/iap-fixes): service bound to the account; starts right after login |
| ☑ | S10-3 / S11-12 | Medium | App | The ⑥ 'Try again' state is broken: iOS StoreKit 2 never emits an error event (failures are only logged), and on Android the error event has an empty product id, so Try again does nothing. | fixed · a03e57ab1 (tora/iap-fixes): ⑥ from buy() errors and the in-flight tier; already-owned / duplicate → restore |
| ☑ | S10-4 | Medium | App (Android) | If Google's buy flow fails to launch, the false return is ignored and the screen stays locked (all cards and Restore disabled) until restart. | fixed · a03e57ab1 (tora/iap-fixes): buy flow not launched → ⑥, screen unlocked |
| ☑ | S10-5 | Medium | App (iOS) | Restore uses current entitlements (which exclude consumables, to confirm on a device), and restored events are credited but never finished. | fixed · a03e57ab1 (tora/iap-fixes): iOS restore reads unfinished transactions; restored ones are finished |
| ☑ | S10-7 / S3-7 | Medium | App ↔ backend | Permanent rejections (bad_signature, wrong_env, unknown_product) are treated as ⑧ and never finished. | fixed · 44bedb57c server record + a03e57ab1 app (tora/iap-fixes): 'couldn't add' banner; iOS finishes, Android left for Google's refund; not resent |
| ☑ | S11-5 | Medium | Creator payout screen (C9) | The big 'Available' figure is the total balance including Sparks still on hold; the withdrawable amount is only in a small line. | fixed · f39a062d9 (tora/iap-fixes): wallet headline shows the withdrawable amount |
| ☑ | S11-4 | Medium | App errors | Two backend messages (on_hold, reason_required) are Chinese and shown verbatim to English users; the app doesn't pre-check amount ≤ withdrawable. | fixed · f39a062d9 (tora/iap-fixes): on_hold / reason_required translated; amount checked before submit |
| ☑ | S11-7 / 9 / 10 | Medium | Goldens (C8) | No golden for ⑥, the footer, the Android ⑬ text, or any Chinese screen. | fixed · 457279a52 (tora/iap-fixes): goldens for ⑥, footer, Android ⑬, the rejected banner and a Chinese screen |
| ☑ | S11-14 | Medium | Goldens (W3) | The welcome-grant golden has a missing-glyph box (Lucide font not loaded in that test). | fixed · f66babefb (tora/iap-fixes): Lucide loaded in the welcome-grant golden; regenerated |
| ☑ | S9-1 / S9-2 | Medium | Spark history | Amounts have no thousands separators; a refund that recovered nothing shows as a green '0 Refund'. | fixed · 7d5305e69 (tora/iap-fixes): thousands separators; zero refund row neutral |
| ☑ | S9-3 / S11-13 | Medium | Resilience | History and balance have no cache, no backoff retry, no pull-to-refresh; offline is shown as 'store unavailable'. | fixed · a0e4c3ef0 (tora/iap-fixes): history + balance cached, backoff retry, pull-to-refresh, offline message |
| ☑ | S9-4 | Medium | Spark history API | No (user_id, id) index for the paging query. | fixed · 55f029940 (tora/iap-fixes): wallet_ledger (user_id, id DESC) index in 000475; plan test |
| ☑ | S2-4 | Medium | Retention | wallet_ledger / wallets / song_tips still cascade on user delete (harmless only while users are never hard-deleted). | fixed · 5ef3d0958 (tora/iap-fixes): guard test — no code or migration may DELETE FROM users |
| ☐ | S12-6 | Medium | Privacy (B9) | The drafted wording promises a 7-year purge job that doesn't exist; merging would put unapproved wording on master (deploy/www is served live). | open |
| ☐ | S12-7 | Medium | Merge | Merge conflicts in en.json, zh.json and admin_dashboard_screen.dart, where master changed _loadInviteStats to take a range. | open |
| ☐ | S1-2 / S1-8 | Medium | Pricing guard | The tier guard measures badges against the entry pack's own price, so entering Apple's expected MYR 4.90 will turn it red. | open |
| ☐ | S1-4 | Medium | Docs | IAP_GREEN_ENERGY.md §一 still has the old USD rate, 'don't restrict to Malaysia' (contradicts D11) and 're-anchor not now'. | open |
| ☐ | S3-3b / S4-9 | Medium | Binding | A just-deleted caller (within the 15-min access token) can still be credited; the liveness check runs outside the credit transaction. | open |
| ☐ | S1-5, S1-6, S1-7 | Low | — | Pricing: payload guard doesn't call the handler; Apple Type/ownership not checked; admin entry-pack price computed from the anchor. | open |
| ☐ | S2-5 … S2-12 | Low | — | Migrations: run a read-only match count before the spend_id backfill; recent earnings go on hold at migration time; no CHECK constraints; spec §6.2 omits two columns; welcome_grant row deletable / no cap; down migrations delete financial rows; brief locks; ownerless line on rejecting a NULL-dev payout. | open |
| ☐ | S3-5, S3-8 … S3-12 | Low | — | Apple: whitelist Type/Environment; caller passed implicitly; no rate limit; price per unit vs quantity; stale IAP_ALLOW_SANDBOX docs; §5.2 error-body shape. | open |
| ☐ | S4-1, S4-2, S4-6 … S4-12 | Low | — | Crediting: IAP-first wallet never gets the grant; grant not atomic on pool paths; no welcome-grant ceiling; any 23505 = already; 'already' ignores stored owner/status; no CHECK green ≥ 0; granted_this_month error shows 0; stale docs. S4-3 (daily_cap doesn't bound grants) is resolved by master commit 619e06b6c (every new account now uses a daily place). | open |
| ☐ | S5-10 … S5-13 | Low | — | Refunds: rounding can hide loss; NULL-dev reversals; matching anchored on purchased_at; consumption status uses the whole wallet. | open |
| ☐ | S6-6 … S6-14 | Low | — | Google: fake server error coverage; 401 doesn't clear token cache; ack races consume (false alarms); error mapping loops; order id from client; regionCode/promo; tokens in logs; env vars undocumented; Android unclaimed rows. | in progress · S6-8 (ack false alarms) fixed 79ce554ae; the rest open |
| ☐ | S7-4, S7-6 … S7-12 | Low | — | Google RTDN: poll single page; cert cache can be wiped; iat / iss / email_verified untested; ack deadline; sweeper per instance; double ack; logging; exact Play permissions. | in progress · S7-4 paging fixed 79ce554ae; the rest open |
| ☐ | S8-5, S8-7 … S8-12 | Low | — | Payouts: totals over first 200 rows; index-defeating casts; share error shows unflagged; negative withdrawable; R4 flags vanish on deletion; ambiguities about ad revenue. | open |
| ☐ | S9-5 … S9-12 | Low | — | History: refunded top-up not marked; dates without year; Load-more guard; unknown kinds only logged locally; loose params; raw refs; footer 'August 2026' date; 'Spark' inside Chinese strings. | open |
| ☐ | S10-8 … S10-15 | Low | — | App lifecycle: per-transaction guard; inFlight cleared by other events; single banner; Android price 0 on early redelivery; Android may acknowledge uncredited; retry-key collision; buy() gaps; wrong ⑫ text for deleted account. | open |
| ☐ | S11-3, S11-6, S11-8, S11-15 … S11-23 | Low | — | Screens: duplicated Spark→MYR rate; hold line wording; '⑥ Retry' vs 'Try again'; W3 audit view / cap / confirm; W3/C9 hidden when dashboard fails; risk-row details and plural; share error shows 0%; payout flow tested only via helpers; formatting; ⑬ URL not tappable; stale header comment; fixture prices. | open |
| ☐ | S12-9 … S12-14 | Low | — | Launch: wrong commit citations; env vars missing from .env.example; doc drift (IAP_GREEN_ENERGY §九 'no Android', the assistant's knowledge base says 'Apple only'); withdrawable field name; CHANGELOG/version at merge. | open |