HoopSpark IAP — independent code review (from §9 Build order)
Asked for by Leong Sen Fong, 2026-09-29 07:26 MYT. Reviewed: branch origin/liva/iap @ 880f05626 (25 commits; none of the IAP work is on master) against docs/iap-en.md. 12 subtasks, one per component, each reviewed read-only; every High and Critical claim was re-checked against the code by Tora Master. Investigation only: no code was changed, nothing was deployed.

1. Overall status

The design is sound and most of it is really built. Every §9 item was reviewed for correctness, not just presence — the per-item verdicts (correct / built with problems / not done) are in the checklist. Every item marked done does exist in its cited commit, the commit order respects the dependency graph, and the core money paths are correct where it matters most — crediting is atomic and idempotent, the Spark amount is decided on the server, the refund attribution rule reproduces both §11.1 worked examples exactly, Google's push authentication fails closed, and the app only finishes a purchase after the backend confirms it.

It cannot ship as it stands. One Critical blocker (the migration numbers) means the branch either won't start or silently breaks wallets, purchases and payouts. Beyond that, 17 High findings are either security issues (Apple certificate check), paths that lose money silently (refund-before-credit, failed Apple refund, Google acknowledge and voided poll, Android product id, Android redelivery, payout settle race), or setup mistakes that would switch Android and R3 off in production.

Critical 1High 17Medium 32Low ~70 (grouped below) — duplicates found by several subtasks are merged into one row (IDs joined with “/”).

One of these is live on master today, outside the IAP branch: S8-1, the payout settle race. Production has no payouts yet, so nothing has been lost.

Subtasks

#ComponentWhat is rightFindings
S1Pricing & catalogue (B1)amounts, prices, badges match §1.2.1; no price sent; amount server-side8
S2Migrations A/B/C (B2, W1, B8)all §6.2 columns match; FK handling sound12
S3Apple verification & binding (B3, B4, B7)ES256 pinned, Apple root confirmed, binding follows D212
S4Crediting & welcome grant (W1, W2)Credit atomic and idempotent; W2 admin-only + audited13
S5Refunds (B5, R1, R3)both §11.1 worked examples exactly right13
S6Google validation (G1–G3, G6, G7)API calls, OAuth, pending handling correct14
S7Google notifications & poll (G4, G5, G8)Pub/Sub auth sound and fails closed12
S8Payout hold, risk lists (R2, R4, R5, F1)hold SQL exact; payout requests locked12
S9Spark history (B6, H1, H2)paging, labels, single entry correct12
S10App purchase lifecycle (C1–C4, C7)finish-only-after-200 rule holds15
S11App screens (C5, C6, C8, W3, C9)wording matches en+zh; states reachable23
S12Build order & launch readinesscommit order respects dependencies14

2. Critical

IDTypeAreaFindingWhy it mattersRecommended correction (not implemented)Where · Spec
S2-1 / S12-1CMigrationsThe three IAP migrations are numbered 000466–000468, but production is already at 000471. The branch also lacks master's 000469–000471.Deployed as-is: the migration tool can't find version 471 and the backend won't start. Merged onto master without renumbering: all three migrations are skipped with no error, and then new-user wallets (welcome grant), every purchase, every tip / shop sale and payouts fail.Merge master into the branch, renumber to 000472 / 000473 / 000474, mark 466–468 void in LEDGER.md, fix the hard-coded numbers in migration_db_test.go / earnings_link_db_test.go, dry-run on a copy of production at 471.backend/migrations/00046{6,7,8}_*; LEDGER.md rows 000466–000468; cmd/api/main.go:99
§6.2, §10.1, iron rule 14

3. High

IDTypeAreaFindingWhy it mattersRecommended correction (not implemented)Where · Spec
S3-1C / PApple verificationReceipts 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.If any other certificate under that root with a privately held key can sign (to be proven), someone could forge purchases and forge REFUND notifications that drain users' Sparks. Critical if exploitable.Require leaf → intermediate → root exactly, check both OIDs, optionally pin the WWDR intermediate; add tests with a chain that lacks the OIDs.iap/verify.go:111–119 (verifyJWS)
§0.1 row 1, §5.2
S3-2 / S5-3 / S7-5PRefunds (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.Buy → keep the app from redeeming → get a refund → redeem the pre-refund receipt = free Sparks.On an unknown transaction, insert a 'refunded' placeholder row so any later credit collides and credits nothing; add a refund-first test.iap/repository.go:139–141 (Refund)
§7, R6
S5-2CApple refundsWhen Refund() fails (database error, deploy restart), the Apple notification handler still answers 200.Apple only resends on a non-200, so that refund is never clawed back — the only trace is a log line. (The Google handler correctly answers 500.)Answer 5xx when Refund fails (it is idempotent); keep 200 only for permanent cases; add a handler-level REFUND test.iap/handler.go:172–178
§7, T7
S1-3 / S6-1PGoogle validationThe 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.If Google does not reject a token paired with a different product id (unconfirmed), buying the MYR 5 pack and claiming …34000 credits 34,000 Sparks.Reject when Google's productId differs from the request; take the amount from Google's productId; make the fake enforce it; add a knife test.iap/google_checker.go:48–52
§5.2 G2/G3, §0.1
S6-2 / S7-2CGoogle 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.A restart or a Google outage of a few minutes loses it. For purchases credited from Google's notification while the app is closed, Google then auto-refunds after 3 days while the Sparks stay credited.Store acknowledge state per Google row; retry durably (e.g. in the daily sweeper) until acknowledged or 3 days; alert near the deadline.iap/google_checker.go:111–131; handler.go:41
G7, G8, §4.3, A7
S7-1CGoogle 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.If the poll can't run for more than 3 days (revoked permission, outage, crash loop), refunds the notifications also missed are lost for good.Persist the last successful sweep and start from it (or always look back ~30 days — Refund is idempotent); follow pagination; alert on repeated failure.iap/google_voided.go:42–63; google.go:229–238
G5, R6
S8-1CPayouts — also live on master todaySettling a payout reads the pending row without a lock and updates it without checking it is still pending.Two admins acting at once can both pay the creator in cash and put the Sparks back (reject + paid), or refund twice.SELECT … FOR UPDATE, or UPDATE … WHERE status='pending' RETURNING and treat 0 rows as bad_state, before writing the refund line. Fix before the first real payout.developer/developer.go:2909–2942 (branch); :2818 on master
R2, F1
S10-1CApp (Android)The app never asks Google for unfinished purchases at start-up; only the manual Restore button does.If the app is killed or the server call fails after payment, the purchase stays stuck: that pack can't be bought again ('You haven't been charged'), and without G8 Google refunds it after 3 days.Call restorePurchases on Android after subscribing, after login and on app resume.app/lib/services/iap_service.dart:85–91
§2.3 ⑧, §7, A2/T9
S2-3 / S5-1CDeleted 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.The deleted-account refund path is dead; the R5 'refunds on deleted accounts' list never shows a deleted account and instead lists refunded unclaimed purchases at full value (no real loss).Define deleted as users.deleted_at IS NOT NULL in Refund's B5 branch and in R5 (or NULL the ids in the purge job — a D4 decision); exclude unclaimed-then-refunded rows from loss totals; rewrite the test.iap/repository.go:147–158; admin_risk.go:127; auth/purge.go
B5, D4, R5, §7
S5-5PApple consumption reply (R3)R3 calls Apple's V1 Send Consumption Information endpoint with V1 fields.Apple's current docs direct apps not on the Advanced Commerce API to V2 (customerConsented, consumptionPercentage, deliveryStatus…); our dispute evidence may be ignored.Move to V2; derive consumptionPercentage from the same unrecovered amount Refund() computes; confirm the API host.iap/consumption.go:44–57, 181–192
R3, §11.5
S5-7 / S12-5CPrivacy (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.HOOP would send usage data to Apple claiming consent the published privacy policy doesn't contain yet.Add an explicit switch that stays off until B9 is published (or don't install the key yet); mark R3 🟡.iap/consumption.go:103; router.go:168
§11.7 R3, B9
S12-3CSetup guideThe 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.Android purchases and R3 would silently stay off in production (one log line at start-up); a loose key in deploy/ is also not git-ignored.Put both keys in deploy/secrets/ and set IAP_GOOGLE_CREDS_FILE / IAP_ASC_KEY_FILE to /secrets/…docs/iap-setup-en.md:153–157, 204–210 (master); deploy/docker-compose.prod.yml:83–85
G1, §4
S12-4C / PSetup 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'.Purchases credited from Google's notification would never be acknowledged → auto-refunded after 3 days.Grant the acknowledge permission too; remove the outdated sentence; list exact permissions in L4.docs/iap-setup-en.md:215–219 (master)
G7, G8, §4.2
S1-1 / S12-8C (process)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.*.If no green.* products exist and Jeff creates spark.*, the top-up grid is empty and every purchase gets unknown_product (retried forever on the phone).Treat B1's IDs as not done until P0.1 is answered and recorded; rename if needed; pin the prefix in a test.iap/products.go:23–28; commit 132da5ecf
§1.3, P0.1, B1
S10-6 / S11-1CApp (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 (??=).After a logout / login on the same phone the next account can see the previous account's Spark balance; the balance also goes stale after spending elsewhere.Reset the service on logout / account change; always apply the fresh read unless a newer credit landed.app/lib/screens/topup_screen.dart:60; util/logout.dart
C6, §5.3
S11-2CApp translationsSix admin strings hard-code 'MYR' (en + zh), which the existing translation-currency guard test forbids.That guard test should fail — a CI blocker once merged.Use {} with kMoneyCode like the other money strings.app/assets/translations/en.json:6885–6888, zh.json same
money single-source rule
S2-2 / S12-2DRegistry & 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).People are told to wait for something that can't happen; the real blocker (S2-1) is hidden.Rewrite the note: 466–468 void, IAP takes 472–474 after a merge with master; keep master's LEDGER as the source of truth.docs/iap-en.md:1151 (master); WIP.md; LEDGER.md
§9

4. Medium

IDTypeAreaFindingWhy it mattersRecommended correction (not implemented)Where · Spec
S3-3 / S6-3CTester 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).Free test Sparks can be credited to a non-tester account the tester can log into; paths disagree.Check the recipient (or both caller and recipient) in one place after binding; reword B7.receipt.go:41; google_checker.go:78; handler.go:79
B7, D2
S6-4PGoogle validationOnly purchaseState 1 and 2 are refused; any other or missing value is credited.A future or malformed state credits money.Credit only when purchaseState == 0.google_checker.go:69–74
G2
S6-5PGoogle configIf the credentials file is set but can't be loaded, the server still starts with Android purchases off (one log line).Every Android purchase then ends in Google's 3-day refund.Fail start-up or surface it in health/admin and alert.router.go:174–177
G1
S7-3PGoogle refundsPartial (quantity) refunds are not handled: the void notification claws back the whole purchase; the poll never sees partial refunds.Over-clawback, and the two paths disagree.Disable multi-quantity in Play Console or read refundType / voidedQuantity.google_notify.go:146–150; google.go:235
G4/G5
S5-4CRefund attribution (R1)Spends with no creator share (AI assistant, self-purchase, shares rounded to 0) are matched again by every later refund.A later refund can miss a creator tip it should reverse; unattributed loss is overstated.Store each refund's matches and compute remaining capacity per spend.repository.go:257–268
§11.1
S5-6CApple 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).Any failure loses the dispute evidence.Persist a job and retry with backoff until the 12-hour deadline.handler.go:166–170
R3
S5-8IApple notificationsEnvironment is never checked; the bundle only if present; inner and outer payloads are not compared.Defence in depth (payload is Apple-signed).Require matching bundle and environment.verify.go:245
§8.4
S5-9DApple notificationsREFUND_REVERSED is not handled.If Apple reverses a refund, the user keeps the loss and creator reversals stand.Decide policy; at least list it for a person.handler.go:161–179
§11
S8-2CPayout review (F1)The free-funded share is computed over earnings since the previous payout up to now, not the earnings inside this payout.The share drifts between listing and settling and can be gamed.Window by releasable_at; store the share on the payout when settled.developer.go:2651–2657
§1.2.6, D8
S8-3CPayout review (F1)A payer who deletes their account counts as 0% free (their wallet ledger cascades away).A burner account tips its welcome grant, deletes itself, and the flag disappears.Treat a payer with earnings but no spend history as 100% free, or snapshot the split at spend time.developer.go:2668; migration 000172
D8
S8-4CBad-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.Bad-debt figures are wrong.Record why the account was gone at refund time; exclude no-loss rows.admin_risk.go:126–127
R5
S8-6PPayout review (F1)Any non-empty note (e.g. a bank reference) satisfies reason_required; approver and share at approval are not stored.The review gate is easy to satisfy by accident; no audit.Separate review_reason, settled_by, share-at-settle columns.developer.go:2917
D8, F1
S4-4PWelcome grantDeleting an account and signing up again with the same email / phone creates a new user and a new welcome grant.Grant farming, cashable through creator payouts.Key grant eligibility on a durable identity hash kept at deletion (product decision).wallet/ledger.go:79–81; auth/repository.go:505–513
§1.2.6, D8/D9
S4-5CWelcome 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.The guard can't catch the most likely regression.Use go/ast; scan SQL inserts; move it to a DB-free package.wallet/welcome_grant_db_test.go:135
W1, §8.4
S10-2CAppThe purchase listener starts only at a cold start while logged in, or when the top-up screen opens — not right after login.iOS Ask-to-Buy / interrupted purchases wait until the next launch.Start it from one post-login hook.main.dart:195
§0.1
S10-3 / S11-12CAppThe ⑥ '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.Users get no feedback, or a dead button; 'already owned' shows 'You haven't been charged'.Take the product from the in-flight purchase; show ⑥ from buy()'s catch; restore on already-owned.iap_service.dart:133–161
§2.3 ⑥
S10-4PApp (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.Stuck screen.Carry the return value; clear in-flight and show ⑥.iap_service.dart:132
C4
S10-5PApp (iOS)Restore uses current entitlements (which exclude consumables, to confirm on a device), and restored events are credited but never finished.Restore likely does nothing on iOS; restored transactions reappear.Use unfinished transactions; complete restored events.iap_service.dart:183; plugin swift:283
§2.2, §7
S10-7 / S3-7DApp ↔ backendPermanent rejections (bad_signature, wrong_env, unknown_product) are treated as ⑧ and never finished.On iOS that pack becomes unbuyable for that Apple ID and the ⑧ banner shows forever.Decide: finish or move to a support state after N tries.iap_service.dart:306–325
§2.3
S11-5CCreator 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.Contradicts R2 and the line beneath it.Show withdrawable as the headline.developer_center_screen.dart:692–713
R2
S11-4CApp errorsTwo backend messages (on_hold, reason_required) are Chinese and shown verbatim to English users; the app doesn't pre-check amount ≤ withdrawable.English-first rule broken; confusing toast.Map these codes to translated keys; validate before submitting.developer.go:2802, 2924
R2, F1
S11-7 / 9 / 10C (gap)Goldens (C8)No golden for ⑥, the footer, the Android ⑬ text, or any Chinese screen.C5/C8 not proven.Add those goldens / tests.app/test/topup_states_0928_test.dart
C5, C8
S11-14CGoldens (W3)The welcome-grant golden has a missing-glyph box (Lucide font not loaded in that test).Invalid golden per project rule.Load the font and regenerate.app/test/admin_welcome_grant_0928_test.dart
W3
S9-1 / S9-2CSpark historyAmounts have no thousands separators; a refund that recovered nothing shows as a green '0 Refund'.Hard to read / misleading at the moment users question their balance.Shared number formatter; neutral display for zero rows.spark_history_screen.dart:198–208
§2.4, §11.1
S9-3 / S11-13PResilienceHistory and balance have no cache, no backoff retry, no pull-to-refresh; offline is shown as 'store unavailable'.Network-resilience rule not met.Cache first, background refresh with backoff.spark_history_screen.dart; topup_screen.dart
project rule
S9-4PSpark history APINo (user_id, id) index for the paging query.Slows down as the ledger grows.Add the index in the renumbered migration.migration 000172
B6
S2-4PRetentionwallet_ledger / wallets / song_tips still cascade on user delete (harmless only while users are never hard-deleted).A future hard delete would erase buyers' top-up journals.Guard test for 'never hard-delete users', or retention for wallet_ledger.migrations 000130/000145/000172
D4
S12-6IPrivacy (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).Promise without a mechanism; publication risk.Merge B9 only after Leong signs off; track the purge job.deploy/www/privacy*.html
B9, D4
S12-7CMergeMerge conflicts in en.json, zh.json and admin_dashboard_screen.dart, where master changed _loadInviteStats to take a range.Needs a careful manual merge and fresh goldens.Keep master's signature, add the branch's loaders; re-run W3/C9 goldens.admin_dashboard_screen.dart:861–902
—
S1-2 / S1-8P / DPricing guardThe tier guard measures badges against the entry pack's own price, so entering Apple's expected MYR 4.90 will turn it red.Pressure to change badges or delete the guard.Measure against the 100 Sparks = MYR 1 anchor.verify_test.go:262–271
§1.2.1, §1.2.5
S1-4DDocsIAP_GREEN_ENERGY.md §一 still has the old USD rate, 'don't restrict to Malaysia' (contradicts D11) and 're-anchor not now'.Readers get the wrong policy.Rewrite §一 to match §1.2.docs/IAP_GREEN_ENERGY.md
B1
S3-3b / S4-9PBindingA just-deleted caller (within the 15-min access token) can still be credited; the liveness check runs outside the credit transaction.Credit lands in a dead wallet.Check the credited account inside Credit's transaction.binding.go:87–92; handler.go:79
§5.3, T16

5. Low (grouped)

IDsSummary
S1-5, S1-6, S1-7Pricing: payload guard doesn't call the handler; Apple Type/ownership not checked; admin entry-pack price computed from the anchor.
S2-5 … S2-12Migrations: 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.
S3-5, S3-8 … S3-12Apple: whitelist Type/Environment; caller passed implicitly; no rate limit; price per unit vs quantity; stale IAP_ALLOW_SANDBOX docs; §5.2 error-body shape.
S4-1, S4-2, S4-6 … S4-12Crediting: 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).
S5-10 … S5-13Refunds: rounding can hide loss; NULL-dev reversals; matching anchored on purchased_at; consumption status uses the whole wallet.
S6-6 … S6-14Google: 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.
S7-4, S7-6 … S7-12Google 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.
S8-5, S8-7 … S8-12Payouts: totals over first 200 rows; index-defeating casts; share error shows unflagged; negative withdrawable; R4 flags vanish on deletion; ambiguities about ad revenue.
S9-5 … S9-12History: 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.
S10-8 … S10-15App 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.
S11-3, S11-6, S11-8, S11-15 … S11-23Screens: 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.
S12-9 … S12-14Launch: 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.

Type legend: C = confirmed from the code · P = potential, needs a device, a sandbox or a store's documentation to settle · D = the documentation is wrong or ambiguous · I = valid choice that could be improved.

6. Missing or incomplete against the spec

7. Architecture and design concerns

8. Questions that need an answer

WhoQuestion
LeongHas P0.1 been answered — do com.hooptech.hoop.green.* products exist in App Store Connect? (decides green.* vs spark.*)
LeongWho renumbers the migrations to 000472–000474 and merges master into liva/iap — Liva Master (branch owner) or someone else?
LeongB9 privacy wording: approved? Until it is published, R3 must stay off (don't install the App Store key yet).
LeongPermanent rejections (bad_signature, wrong_env, unknown_product): finish the transaction, or keep retrying forever (today's behaviour, which blocks that pack on iOS)?
LeongWhat does 'deleted account' mean for refunds and the bad-debt list, now that users rows are never deleted? (users.deleted_at)
Leong / Li MinShould delete-and-re-sign-up earn a new welcome grant? Should REFUND_REVERSED re-credit the user?
LeongPayout review: window for the free-funded share (this payout's earnings vs since last payout)? Separate approval reason and approver?
Needs a store's docs / sandboxDoes Google's products.get reject a token with the wrong productId? Which Play permission does acknowledge need? Does Google report auto-refunds of unacknowledged purchases as voided? Does iOS Restore return unfinished consumables?
Needs a scratch testCan any certificate under Apple Root CA G3 with a third-party key pass today's check (S3-1)?

9. Recommended next steps (not implemented — waiting for your instruction)

  1. Before anything can ship: merge master into liva/iap, renumber the migrations to 000472–000474, void 466–468 in LEDGER, dry-run on a copy of production (S2-1, S2-2).
  2. Security and money-loss fixes first: Apple certificate OIDs (S3-1); refund-before-credit placeholder (S3-2); 5xx on failed Apple refund (S5-2); compare Google's productId (S1-3); durable Google acknowledge (S6-2); poll cursor and paging (S7-1); payout settle lock — this one is also on master today (S8-1).
  3. App: Android restore at start / login / resume (S10-1); reset state on logout (S10-6); start after login (S10-2); fix ⑥ (S10-3); MYR strings (S11-2).
  4. Setup guide: key paths under deploy/secrets (S12-3) and the Play acknowledge permission (S12-4) — before Jeff installs anything.
  5. Gate R3 behind B9 and move it to Apple's V2 API (S5-5, S5-7).
  6. Decisions from you (questions below), then the Medium list, then the Low list.
  7. Then run §8 on both platforms (Phase 6), including the store-dependent checks listed in the questions.

Full per-subtask reports (file:line evidence for every finding) are kept with the review and can be posted on request.