| ๆกฃๆก | |
|---|---|
name |
iap-en |
summary |
HoopSpark in-app purchases (iOS + Android, six packages) โ what already exists, screen-by-screen wireframes, and a phased build plan. The iOS half is already built and stuck on App Store Connect; Android does not exist at all. |
๐ณ HoopSpark In-App Purchases โ Wireframes and Build Plan (iOS + Android)
What this is: the complete wireframe and implementation plan Leong Sen Fong asked for on 2026-09-22, with the explicit instruction to inspect the existing codebase before designing.
๐จ๐ณ Chinese version:
/iapโ same content, same structure.The one sentence that matters most, up front: this is not a greenfield project. The iOS half is already built โ offline signature verification, idempotent crediting, refund clawback, the top-up screen, all of it on master. What it is blocked on is App Store Connect configuration, not code. Android genuinely does not exist. So everything below is "finish and unify", not "start over".
0. What exists today (all freshly checked, not copied from old docs)
Verified against origin/master @ e9523ea8b, 2026-09-22.
0.1 Built and working (iOS)
| Piece | Location | State |
|---|---|---|
| Apple offline verification | backend/internal/domain/iap/verify.go |
โ Complete. Embedded Apple Root CA G3, verifies the x5c chain, validity window and ES256 signature โ does not depend on Apple's servers being up |
| Business checks | verify.go Check() |
โ
Bundle ID, environment (sandbox rejected in production), revocation date, unknown product, quantity > 1 |
| Idempotent crediting | repository.go Credit() |
โ
Ledger + wallet + journal in one transaction; iap_transactions.transaction_id is the primary key, so a replay collides |
| Refund clawback | repository.go Refund() |
โ |
| Recovering the recipient's share | none โ Refund() never touches dev_ledger |
โ see ยง11 |
| Payout hold window | none | โ see ยง11 |
CONSUMPTION_REQUEST reply |
none (named only in a comment at verify.go:202) |
โ see ยง11 |
| App Store Server Notifications V2 | handler.go notify() |
โ
Unauthenticated by design, identity proven by JWS signature; REFUND / REVOKE claw back automatically |
| Product catalogue | products.go |
โ Six tiers. The amount credited is decided server-side; whatever the client reports is ignored |
| Client purchase flow | app/lib/services/iap_service.dart |
โ
194 lines. completePurchase() only after the backend returns 200 โ this is what makes crashes and dropouts self-healing |
| Top-up screen | app/lib/screens/topup_screen.dart |
โ 292 lines. Store-localised prices, restore, non-refundable notice, terms links |
| Subscribes at launch | app/lib/main.dart:193 |
โ
start() on login, not when the screen opens โ otherwise an unfinished transaction has nobody listening |
| Tables | migration 000172 |
โ
iap_transactions + wallet_ledger |
| Plugin | pubspec.yaml:104 in_app_purchase: ^3.2.1 |
โ This plugin already supports both platforms |
The client's rule deserves repeating (from the header of iap_service.dart):
Call
completePurchase()only after the backend has returned 200.
Do it the other way round and a dropped connection or a crash leaves the transaction permanently marked complete by the store, unreachable forever โ the user paid and the Sparks vanish, with no way to recover them. Because that rule is held, an unfinished transaction is re-delivered on the next launch, re-submitted automatically, and the backend's primary key makes the retry harmless. Android must copy this rule exactly.
0.2 Entirely missing (Android)
I scanned backend/ and app/lib/ for androidpublisher, play.?billing, purchaseToken
and google.?play. Zero hits in the backend โ only docs saying it was never built. Missing:
- Server-side validation of Google Play purchases (needs the Play Developer API + a service account)
- Real-time Developer Notifications (RTDN, over Pub/Sub) โ the Apple equivalent already exists
iap_transactionshas noplatformcolumn, and its primary key is Apple's transaction ID- The six products do not exist in Play Console
- No
com.android.vending.BILLINGin ourAndroidManifest.xmlโ โ ๏ธ this may not be a gap:in_app_purchase_androiddeclares it in its own manifest and Gradle merges it. First task is to unpack a release build and runaapt dump permissionsto confirm, rather than adding a possibly-duplicate line.
0.3 Real production numbers (queried just now)
iap_transactions 0 rows โ nobody has ever bought anything
payouts 0 rows โ nobody has ever been paid
dev_ledger 24 rows / 6 developers / 1,229 Sparks (โ MYR 12.29)
That zero is the most valuable number in this plan. Re-anchoring the tiers costs almost nothing: there is no purchase history to stay consistent with, and no user holds a balance bought at the old price. Right now is the cheapest this will ever be.
โ ๏ธ But zero transactions does not prove the products are absent from App Store Connect or Play Console. A product ID, once created, can never be reused on either store. Step one is for a human to log into both consoles and look โ that is the one thing I cannot see from here.
1. How the six packages should be organised
1.1 Product types: all six consumable. No non-consumables, no subscriptions.
- Consumable โ you buy it, it becomes Sparks, you spend them. All six are this.
- Non-consumable โ none. HOOP has nothing you buy once and own forever.
- Subscriptions โ no store subscriptions at all, and there should not be any.
โ ๏ธ Easy to get wrong here: the AI assistant does have weekly / monthly / yearly plans
(
assistant.go:88-92โ 400 / 1,200 / 12,000), but those are paid in Sparks, not through StoreKit or Play Billing. The wallet is debited; no store is involved. The principle: real money enters through exactly one door โ buying Sparks. Everything after that is internal. This is worth defending. A second real-money entrance means refunds, tax handling and reconciliation all have to be built twice.
1.2 Pricing: Spark amounts stay, prices anchored directly in MYR
๐๏ธ Decided by Leong, 2026-09-22: the six tiers keep exactly the Spark amounts they have today, and prices are set directly in MYR โ never by converting from USD. This section previously proposed a different set of six tiers with a narrower bonus ladder; that version is retired, and why is recorded in 1.2.3.
1.2.1 The price table (single source of truth; products.go follows this)
The floor is anchored to the rate Li Min set on 2026-09-15: 100 Sparks = MYR 1. The smallest tier sits exactly on that rate; the larger tiers are not discounts โ they give more Sparks for the same money.
| product id | Price | Sparks | Sparks/MYR | Bonus vs base | Role |
|---|---|---|---|---|---|
com.hooptech.hoop.spark.500 |
MYR 5.00 | 500 | 100 | โ | Entry; the base rate itself |
com.hooptech.hoop.spark.1600 |
MYR 14.90 | 1,600 | 107 | +7% | |
com.hooptech.hoop.spark.3400 |
MYR 30.00 | 3,400 | 113 | +13% | |
com.hooptech.hoop.spark.6500 |
MYR 55.00 | 6,500 | 118 | +18% | Most popular (highlighted) |
com.hooptech.hoop.spark.14000 |
MYR 110.00 | 14,000 | 127 | +27% | |
com.hooptech.hoop.spark.34000 |
MYR 250.00 | 34,000 | 136 | +36% | Large |
No bonus badge has to change: the percentages land exactly on the BonusPct values already
written in products.go โ 0 / 7 / 13 / 18 / 27 / 36 โ so the existing UI badges stay as they are.
โ ๏ธ Rounding is always downward (14.95 โ 14.90, 55.08 โ 55.00). The direction is deliberate: rounding down means the buyer gets slightly more bonus than the badge claims, never less. The other direction would make the badge a lie.
1.2.2 The worst case, worked out (this is the only real constraint on pricing)
Worst case = an entire pack spent wholly on music tips, where the creator takes 50%
(revshare.go:27). Our side of that liability is valued at the accounting rate, 100 Sparks = MYR 1.
| Sparks | Price | We keep after Apple's 30% | Worst-case payout | Margin | Margin on Small Business (15%) |
|---|---|---|---|---|---|
| 500 | 5.00 | 3.50 | 2.50 | +1.00 | +1.75 |
| 1,600 | 14.90 | 10.43 | 8.00 | +2.43 | +4.66 |
| 3,400 | 30.00 | 21.00 | 17.00 | +4.00 | +8.50 |
| 6,500 | 55.00 | 38.50 | 32.50 | +6.00 | +14.25 |
| 14,000 | 110.00 | 77.00 | 70.00 | +7.00 | +23.50 |
| 34,000 | 250.00 | 175.00 | 170.00 | +5.00 | +42.50 |
All six are positive in the worst case โ which is exactly what the re-anchor was for: under the old USD pricing the largest tier was โMYR 12.53 (arithmetic kept in 1.2.4 for contrast).
โ ๏ธ But see where it is thin: the +MYR 5.00 on the largest tier is 2% of the sale price. The cause is not an underpriced tier โ it is the 36% bonus ladder and the 50% creator share together consuming most of what Apple leaves behind.
๐ด The one sentence worth taking from this section: enrolling in the App Store Small Business Program (30% โ 15%) is worth more than any price point on this table โ with the very same prices, the top tier's margin goes from MYR 5 to MYR 42.50, more than eight times better, without changing a single number. It is already a checkbox in ยง10.1, but there it looks like a formality; it is in fact the highest-return step on this whole track.
1.2.3 Why the earlier "narrow the bonus ladder" proposal was dropped
This section used to propose a different six (500 / 1,050 / 2,750 / 5,700 / 11,800 / 24,000, with the bonus capped at +18%), specifically to widen that 2% margin. Leong's decision on 09-22 was to keep the existing Spark amounts, so that version is retired.
The cost is recorded here rather than hidden: keeping the 36% bonus ladder means accepting a 2% worst-case margin on the largest tier. That is not a wrong choice โ what it buys is that the catalogue need not be rebuilt, no badge changes, and no buyer sees less content than before. But it does turn the Small Business Program from an option into effectively a precondition.
1.2.4 The old USD pricing, for contrast (do not build from this)
Buyer pays US$49.99 โ MYR 224.96 (at 1 USD = 4.5 MYR)
Apple takes 30% โ we keep MYR 157.47
That pack mints 34,000 Sparks
If all of it goes to music tips (creator takes 50%)
โ we owe the creator 17,000 Sparks = MYR 170.00
Net 157.47 โ 170.00 = โ12.53 โ a loss
1.2.5 This table still cannot be typed into the consoles as-is
โ ๏ธ "Price" is not the same as "a price you can enter": both stores make you pick from their own price-point grid, not type an arbitrary number. The table above is the target anchor; the real work is picking the nearest available point in each console โ a person has to confirm this, and I will not invent Apple's Malaysian price grid for them. Once picked, come back and change this table to the numbers actually entered, and recompute the six rows in 1.2.2.
1.3 Product ID / SKU naming
Rule: com.hooptech.hoop.spark.<spark amount>
- Prefix matches the bundle ID and applicationId (
com.hooptech.hoop, identical on both platforms โ seebuild.gradle.kts:50) - Use
spark, notgreen: the public name changed on 2026-09-15, and a product ID can never be changed. Do not carve a retired name into something permanent. - Suffix is the Spark amount, not a tier number:
t1โฆt6tells you nothing, while the amount is fixed for the life of the product (changing it means a new product anyway) - Same IDs on both platforms โ that is what lets the backend keep one mapping table
๐๏ธ Since 2026-09-22 there is a cheaper path here โ read this first
Now that the Spark amounts no longer change, the only remaining reason to create six new products is the rename โ and product ids are invisible to users:
topup_screen.dart:153uses the id purely as a lookup key (_svc.products[id]) and never draws it on screen.So if Phase 0 finds that the six
โฆgreen.500products already exist in ASC: just change their prices โ do not create six and retire six. Price is editable, and the Spark amount lives in our own backend table, not in the store.The criterion: this re-anchor changes price, not content. New ids are only unavoidable when the content amount changes. The cost is that the retired word
greenstays in the id forever โ but it lives only in the two console back-office lists, nowhere a user can see.โ ๏ธ Either path can only be settled after a person checks the consoles. That is Phase 0.
โ ๏ธ Google Play's ID rules are stricter: lowercase letters, digits, underscores and periods only, and it must start with a letter or digit. The naming above is legal on both โ but on both, an ID that has been created can never be reused (Apple says so explicitly; Play blocks re-creating a deleted ID). Do not press "create" in either console until this table is final.
2. Wireframes (screen by screen, state by state)
2.1 Screen inventory
| # | Screen | State |
|---|---|---|
| A | Wallet / balance entry point | Exists |
| B | Top-up screen (six tiers) | Exists โ topup_screen.dart |
| C | Native store purchase sheet | Drawn by the store, not ours |
| D | Credited celebration | Exists (SnackBar) |
| E | Failed / cancelled | Partial โ logs only, nothing the user sees โ ๏ธ |
| F | Restore purchases | Exists (app bar action) |
| G | Products unavailable | Exists โ _unavailable() |
| H | Purchase history | โ Missing. The backend GET /v1/wallet/ledger exists and api_client.dart:4551 already wraps it โ zero callers |
2.2 Screen B โ top-up
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ Top up Restore โ โ Restore disabled while pending
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Your balance โ โ
โ โ โ๏ธ 1,250 โ โ โ shows "โ" when unknown, never 0
โ โ Sparks are spent inside HOOP โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ 500 โ โ 1,600 โ โ
โ โ Sparks โ โ Sparks โ โ
โ โ โ โ +7% โ โ
โ โ RM 5.00 โ โ RM 14.90 โ โ โ must be the store's localised string
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ 3,400 โ โ 6,500 โ POPULAR โ
โ โ +13% โ โ +18% โ โ
โ โ RM 30.00 โ โ RM 55.00 โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ 14,000 โ โ 34,000 โ โ
โ โ +27% โ โ +36% โ โ
โ โ RM 110 โ โ RM 250 โ โ
โ โโโโโโโโโโโโ โโโโโโโโโโโโ โ
โ โ
โ Prices are shown in your local โ
โ currency by the store. Hoop Spark โ
โ is non-refundable, cannot be โ
โ transferred or cashed out. โ
โ Terms ยท Privacy โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Three things that must not change (already written at the top of topup_screen.dart; both
stores check them at review):
- The price must be the store's localised string. Hardcoding gets the app rejected, and shows the wrong currency and amount to anyone outside Malaysia.
- The footer must state non-refundable / non-transferable / cannot be cashed out, with links to terms and privacy.
- Restore is not decoration. Stores do not "restore" consumables, but the action makes the store re-deliver unfinished transactions โ it is the user's own way out of "I paid and got nothing".
2.3 Every state
โ Loading six skeleton cards, Restore disabled
โก Normal as above
โข One tier busy that card's price becomes a spinner; the other five disable (one at a time)
โฃ Success green SnackBar: โก 500 Sparks added; balance card animates
โค User cancelled silent. Nothing. โ cancelling is not an error; do not punish it with a dialog
โฅ Failed red SnackBar + "Try again" โ ๏ธ currently logs only โ this is work to do
โฆ Already credited "Already added", balance refreshes, no second celebration
โง Paid but not yet confirmed โ ๏ธ the most important missing screen:
"Payment received. Adding your Sparksโฆ
This can take a moment. You can close this page โ
it will finish by itself."
โ must not say failed (we took the money) and must not say
success (it has not landed yet)
โจ Store unavailable "The store isn't available right now" + Retry
โฉ Zero products same as โจ โ usually not a code bug but an unfinished console agreement
โช Pending (Android) "Waiting for your payment to clear"
โ Android-only: some payment methods (cash at a store) take days
โง and โช are the two new states, and neither is decoration. โง is the only explanation a user
gets during the seconds when their money is gone and their Sparks have not arrived. โช is Android's
PENDING transaction, effectively unseen on iOS โ without it, a user assumes the purchase failed
and buys again.
2.4 Screen H โ purchase history (new)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ Spark history โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ๏ธ +500 Top up 22 Sep โ
โ RM 5.00 ยท Apple โ
โ โ๏ธ โ1,200 AI assistant 20 Sep โ
โ โ๏ธ โ50 Tip ยท song 19 Sep โ
โ โ๏ธ +100 Daily check-in 18 Sep โ
โ โ๏ธ +1,000 Welcome gift 01 Sep โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ Load more โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The backend is already there (wallet_ledger, with kind in grant / iap_topup / purchase / tip /
refund / admin / checkin) and api_client.dart:4551 already wraps it โ only the screen is
missing. โ ๏ธ The journal only starts in August 2026; earlier balance changes have no rows.
The footer has to say so, or users will think we lost their history.
2.5 Navigation
"Me" tab โ wallet card โโฌโโ [Top up] โ B top-up โ C store sheet โ D/E
โโโ [History] โ H purchase history (new)
3. User flow / state diagram
โโโโโโโโโโโโโโโโ
โ idle โ
โโโโโโโโฌโโโโโโโโ
โ taps a tier
โผ
โโโโโโโโโโโโโโโโ cancelled in store sheet
โ purchasing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ idle (silent)
โโโโโโโโฌโโโโโโโโ
โ store reports purchased / restored
โผ
โโโโโโโโโโโโโโโโ
โ redeeming โ โ send the receipt to our backend
โโโโฌโโโโโโโโโฌโโโ
โ โ
200 OK non-200 / timeout / offline
โ โ
โผ โผ
โโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ creditedโ โ unconfirmed โ
โ finish()โ โ **never finish** โ
โโโโโโโโโโโ โ transaction stays at the โ
โ store; next launch โ
โ re-delivers โ redeeming โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Android adds one branch:
purchasing โโโ โโโโโโโโโโโ clears days later โโโโโโโโโโโโ
โ PENDING โโโโโโโโโโโโโโโโโโโโโ PURCHASEDโโ redeeming
โโโโโโโโโโโ โโโโโโโโโโโโ
โ ๏ธ grant zero Sparks while PENDING
The whole diagram reduces to one rule: nothing that can fail may sit between credited and
finish(), and unconfirmed must be a state that can walk itself out, not a dead end.
4. What each store needs configured
4.1 Apple / App Store Connect
| # | Step | Who | Symptom if skipped |
|---|---|---|---|
| 1 | Sign the Paid Apps Agreement | Account holder | No products are found at all; the screen shows "unavailable" |
| 2 | Tax forms + bank account | Account holder | Same โ the agreement is not in effect |
| 3 | Create 6 Consumable products with the IDs from ยง1.3 | Console | โ |
| 4 | Localised name / description + review screenshot for each | Console | Products fail review |
| 5 | Set prices (nearest Malaysia tier to ยง1.2) | Console | โ |
| 6 | Create sandbox test accounts | Console | Cannot test |
| 7 | Point Server Notifications V2 at POST /v1/iap/apple/notify |
Console | Refunds never claw back |
| 8 | Apply to the Small Business Program (30% โ 15%) | Account holder | Hands Apple half the margin (see ยง1.2) |
โ
On our side: step 7's endpoint is already written, signature checks and REFUND/REVOKE
clawback included.
4.2 Google / Play Console
| # | Step | Who | Note |
|---|---|---|---|
| 1 | Developer account + merchant (payments) account | Account holder | US$25 one-off |
| 2 | Upload a release build to an internal testing track | NOVA | โ ๏ธ Products cannot be created until a build is uploaded |
| 3 | Create 6 one-time (consumable) products, IDs per ยง1.3 | Console | Must match iOS exactly |
| 4 | Set Malaysia prices | Console | โ |
| 5 | Create a service account, grant Play Developer API access, download JSON credentials | Account holder | ๐ Credentials travel as a file to the server โ never into git, never pasted into chat |
| 6 | Enable RTDN: Pub/Sub topic โ push to POST /v1/iap/google/notify |
Console | Apple's step 7 equivalent |
| 7 | Internal test track + licensed tester accounts | Console | Testers are not charged |
4.3 The differences that actually bite
| Apple | ||
|---|---|---|
| Receipt | Signed JWS, verifiable offline | Purchase token, must be exchanged online via Google's API |
| Secrets needed | โ None (the embedded root cert suffices) | โ Service account credentials โ one more secret to protect |
| Idempotency key | transactionId |
orderId (โ ๏ธ can be empty in some cases; fall back to purchaseToken) |
| Linking to a user | appAccountToken |
obfuscatedAccountId |
| Finishing | finishTransaction |
Must explicitly consume, or the user cannot buy that tier again |
| Pending transactions | Rare | Common (cash/bank payments can take days) |
| Refund notice | Server Notifications V2 | RTDN (Pub/Sub) + Voided Purchases API |
| Sandbox | Sandbox accounts, purchases free | Test track + licensed testers |
โ ๏ธ "Must verify online" changes the availability design. On iOS, crediting works even if Google
is down. On Android, if Google's API is unreachable we must take the unconfirmed path โ do not
finish, do not consume, retry next launch. The good news: that path already exists for Apple and
Android reuses it unchanged.
5. Architecture
5.1 Layers (reusing what exists โ no new technologies)
โโ UI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ topup_screen.dart tiers ยท states โ
โ spark_history_screen.dart (new) โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ reads IapService fields only; never touches a store SDK
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ IapService (exists, extended to two platforms) โ
โ ยท subscribe to purchase stream ยท load tiers โ
โ ยท start a purchase โ
โ ยท **finish only after the backend returns 200**โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ApiClient
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ backend domain/iap โ
โ handler.go products / verify / notify / โ
โ **google_verify / google_notify (new)** โ
โ verify.go Apple offline verification (exists) โ
โ google.go Google online validation (new) โ
โ repository.go idempotent credit ยท refund (exists, + platform cols) โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โผ
Postgres: iap_transactions ยท wallets ยท wallet_ledger
Deliberately not doing: no payment middleware, no new state management library, no separate
Android table. The in_app_purchase plugin already covers both platforms, and the backend pipeline
(verify โ credit idempotently โ journal) is platform-neutral. The only fork is "how do I check
this receipt".
5.2 New backend surface
| Endpoint | Purpose | Auth |
|---|---|---|
POST /v1/iap/google/verify |
Exchange a purchase token for Sparks | User token |
POST /v1/iap/google/notify |
RTDN push (refunds / revocations) | Unauthenticated, identity proven by the Pub/Sub JWT โ ๏ธ |
โ ๏ธ Like Apple's, google/notify is a public endpoint that carries none of our tokens. Apple's is
secured by the certificate chain. Google's must verify the Pub/Sub push OIDC token โ aud must
match our own endpoint and the sender must be the expected service account.
Skip that and anyone can POST a "refund" and drain someone's Sparks. Follow the shape of
verify.go: verify first, touch the ledger second.
6. Database
6.1 Existing (structure unchanged, columns added)
iap_transactions
transaction_id TEXT PRIMARY KEY -- โ ๏ธ currently holds Apple's ID
original_transaction_id TEXT
user_id UUID โ users(id) ON DELETE CASCADE
product_id TEXT
green BIGINT -- server-computed, client never trusted
environment TEXT -- Sandbox / Production
status TEXT -- credited / refunded
purchased_at TIMESTAMPTZ
created_at TIMESTAMPTZ
raw_jws TEXT -- the raw receipt, kept as evidence
wallet_ledger
id, user_id, kind, green, balance, ref, created_at
6.2 What this version adds (one migration)
-- Take the number from backend/migrations/LEDGER.md and write back +1 immediately
ALTER TABLE iap_transactions
ADD COLUMN IF NOT EXISTS platform TEXT NOT NULL DEFAULT 'apple', -- apple / google
ADD COLUMN IF NOT EXISTS purchase_token TEXT NOT NULL DEFAULT '', -- Google's; empty for Apple
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'credited', -- pending / credited / refunded
ADD COLUMN IF NOT EXISTS currency TEXT NOT NULL DEFAULT 'MYR', -- what the store actually charged
ADD COLUMN IF NOT EXISTS price_micros BIGINT NOT NULL DEFAULT 0; -- what the store actually charged
-- Google's idempotency key: orderId can be empty, purchase_token is the one always present
CREATE UNIQUE INDEX IF NOT EXISTS iap_tx_google_token
ON iap_transactions(purchase_token) WHERE purchase_token <> '';
The decisions here and why:
- No separate
google_transactionstable. The two platforms share ~90% of their fields. Split them and "how much has this user topped up" has to be written twice โ and when two implementations drift, nothing on screen shows it. platformdefaults to'apple'. The table is empty today (0 rows), but the default means this migration is also safe on any environment that does have rows.purchase_tokengets a partial unique index, not a primary key. It is an empty string on the Apple side, and empty strings cannot be globally unique โ henceWHERE purchase_token <> ''.price_micros+currency: what the store actually charged. This is the only place we can learn what the user really paid, and buyers are charged in their own storefront currency (an accepted, documented exception). Without these two columns, reconciliation is guesswork. โ ๏ธ Related: thepayoutstable still has no currency column at all โ that is where money actually leaves the company, and it needs one more than this table does. Reported separately.
6.3 Tables deliberately not created
- A products table. The catalogue lives in code (
products.go); changing tiers needs no migration. โ ๏ธ Do not "tidy" it into the database โ that creates a second source of truth, and the credited amount must be compiled into the binary, not a row anyone can UPDATE. - An entitlements table. Sparks are the entitlement;
wallets.greenalready is that record. A second table would be the same fact stored twice.
7. Edge cases
| Case | Handling | Exists? |
|---|---|---|
| Purchase failed | Finish it (otherwise it redelivers forever) + show the user something | โ ๏ธ logs only; the message is work to do |
| User cancelled | Finish it, stay silent | โ |
| Pending (Android) | Grant nothing, show state โช, wait for RTDN or the next query | โ to build |
| Duplicate transaction | Primary key / partial unique collides โ 200 with already:true, no double credit |
โ (Apple side) |
| Restore | Re-delivers unfinished transactions; stores do not "restore" consumables, but this is the user's escape hatch | โ |
| Refund | Apple REFUND/REVOKE โ clawback; Google RTDN + Voided Purchases. Only the buyer's side is recovered; the recipient's side is not โ ยง11 |
โ ๏ธ see ยง11 |
| Chargeback | Stores treat it as a refund; same path | as above (ยง11) |
| Refund after the Sparks were spent | Already handled, and better than what I would have proposed: Refund() claws back down to zero, never negative, and marks the journal ref with :short when it could not take the full amount (repository.go:128) |
โ logic exists; nothing reads the marker โ ๏ธ see below |
| Expired subscription | Not applicable โ there are no store subscriptions (ยง1.1) | โ |
| Products unavailable | States โจ/โฉ + Retry. Nine times out of ten it is an unfinished console agreement, not a bug | โ |
| Offline | Do not finish; retry next launch | โ |
| Killed mid-transaction | Same โ the store re-delivers | โ |
| Switching devices | Sparks live on the server account | โ |
| Reinstalling | Same; unfinished transactions re-deliver at launch | โ |
| Same account on several devices | Balance is server-side, and concurrent credits are serialised by the row lock in UPDATE โฆ RETURNING |
โ |
| Account deletion | iap_transactions.user_id is ON DELETE CASCADE (migration 000172) โ deleting a user deletes their financial ledger too |
โ behaviour confirmed; โ whether that is acceptable is a legal/finance decision, not a technical one |
โ ๏ธ The refund case is worth singling out, because I chased it to the end. When the clawback
cannot take the full amount, the code writes :short on the journal row, meaning "this one has bad
debt". I grepped the whole repository: one place writes it, nothing reads it.
A marker with no reader is not a safeguard. Bad debt would accumulate in silence, with nothing and
nobody ever speaking up. The rule worth keeping: whenever you write a counter, a log line or a
flag, name who reads it โ if you cannot, it is not done. So the thing to add in Phase 3 is not more
logic, it is somewhere :short surfaces (an admin row, or a periodic reconciliation), rather
than another marker nobody looks at.
8. Testing plan
8.1 Shared acceptance criteria
Every one of these must be seen to fail first before it is trusted:
| # | Case | Passes when |
|---|---|---|
| T1 | Normal purchase | Balance rises by the right amount; one credited row; one iap_topup journal row |
| T2 | Same transaction resubmitted | 200 with already:true; balance unchanged; no extra row |
| T3 | Backend forced to 500 | Client does not finish; relaunch retries and credits |
| T4 | Sandbox receipt against production | Rejected (wrong_env); balance unchanged |
| T5 | Forged receipt | bad_signature; balance unchanged |
| T6 | Unknown product ID | unknown_product; balance unchanged |
| T7 | Refund notification | Balance clawed back; ledger refunded |
| T8 | Forged refund notification | Rejected; balance unchanged |
| T9 | Killed mid-purchase | Credited on relaunch, exactly once |
| T10 | Purchase while offline | No money lost; heals when back online |
8.2 Android-specific
| # | Case | Passes when |
|---|---|---|
| A1 | PENDING transaction | No Sparks granted; state โช shown; credits automatically once cleared |
| A2 | Buy the same tier twice without consuming | Reproduce once, to prove we really do consume |
| A3 | Google API unreachable | Takes the unconfirmed path; heals later |
| A4 | RTDN with a bad signature | Rejected |
| A5 | Empty orderId |
purchase_token serves as the idempotency key; no double credit |
8.3 iOS-specific
| # | Case | Passes when |
|---|---|---|
| I1 | Family-shared transaction (FAMILY_SHARED) |
Decide whether to grant โ the code does not check this today โ ๏ธ a product decision |
| I2 | quantity > 1 |
Credits per unit (verify.go implements it; run it once for real) |
8.4 Knife tests (break each guard on purpose)
- Remove the idempotency step โ T2 must go red
- Remove the environment check โ T4 must go red
- Finish before the backend confirms โ T3 must go red
- Remove RTDN signature verification โ T8 must go red
A guard that has never been seen to fail is not a guard.
9. Build order (phases and dependencies)
Principle: the system is shippable at the end of every phase. Nothing half-built is left behind.
Phase 0 โ look in both consoles (half a day, human only)
- Do those six product IDs already exist in App Store Connect?
- Is the Paid Apps Agreement in effect (tax + banking complete)?
- Do we have a Play developer account and a merchant account?
โ ๏ธ This answer changes everything after it. If โฆgreen.500 already exists in ASC, the re-anchor
in ยง1.2 means "create six new products and retire six old ones". If it was never created, it is just
editing numbers. This is the one step that must happen before anything else, and the one I cannot
do.
Phase 1 โ re-anchor pricing (backend, 1 day)
- [ ]
products.go: the six tiers from ยง1.2;USDCentsโPriceMYRSen - [ ] Stop sending the price field to clients โ
usd_centsreally is in the payload today, while two separate comments claim it is not - [ ] Update the tier guard in
verify_test.goto MYR โ do not delete it; it is the one thing that will speak up if the re-anchor goes wrong - [ ]
IAP_GREEN_ENERGY.mdupdated in the same commit
Depends on: Phase 0. Does not depend on Android.
Phase 2 โ database + backend abstraction (1 day)
- [ ] Migration from ยง6.2 (take the number from
LEDGER.md, write back +1 immediately) - [ ]
repository.gorecordsplatform/purchase_token/currency/price_micros - [ ] Extract "verify a receipt" into an interface, with Apple as the first implementation โ extract before adding, not after
Phase 3 โ Google validation (backend, 2-3 days)
- [ ] Service account credentials: file on the server, path in
.env, never in git - [ ]
google.go: validate purchase tokens against the Play Developer API - [ ]
POST /v1/iap/google/verify - [ ]
POST /v1/iap/google/notify+ Pub/Sub OIDC verification (see the warning in ยง5.2) - [ ] Reuse the existing
Refund()for clawback - [ ] Somewhere
:shortbad debt becomes visible (see ยง7) - [ ] Fake-server tests: โ ๏ธ the stand-in must copy the real API's auth and error codes, or it only proves "I sent a request"
Phase 4 โ client, both platforms (2 days)
- [ ]
IapService: pick the verify endpoint by platform; handle AndroidPENDING - [ ] Confirm Android really does consume (A2)
- [ ] Build states โง (paid, not yet confirmed) and โช (pending)
- [ ] Failures get something the user can read, not just a log line
- [ ] Goldens: the six tier cards and the three new states
Phase 5 โ purchase history (1 day, parallel)
- [ ]
spark_history_screen.dartreading the existingGET /v1/wallet/ledger - [ ] Entry point on the wallet card
- [ ] Footer explaining the journal starts in August 2026
Depends on no store configuration at all โ it can be finished and shipped first.
Phase 6 โ sandbox testing (2 days, a human present)
Run all of ยง8, once per platform.
Phase 7 โ launch
- [ ] Small Business Program applied for (the arithmetic in ยง1.2)
- [ ] Re-confirm
AllowSandbox = falsein production - [ ] Both notification endpoints have received a real notification in production
- [ ] Release one tier to an internal account and buy it with real money, then check the ledger, the journal and the balance all agree
Dependency graph
Phase 0 (human) โโโฌโโโ Phase 1 โโโ
โโโโ Phase 2 โโโดโโโ Phase 3 โโโ Phase 4 โโโ Phase 6 โโโ Phase 7
Phase 5 (independent, any time)
10. Checklists
10.1 Before launch
Apple - [ ] Paid Apps Agreement in effect (tax + banking) - [ ] Six consumable products created and approved - [ ] Prices set from the Malaysia tier table - [ ] Server Notifications V2 pointed at production, and one real notification received - [ ] Sandbox accounts working - [ ] Small Business Program applied for
Google - [ ] Developer and merchant accounts ready - [ ] A release build is on an internal track (products cannot exist before this) - [ ] Six one-time products created with IDs identical to iOS - [ ] Service account credentials on the server, not in git - [ ] RTDN topic and push endpoint working, and one real notification received - [ ] Licensed tester accounts working
Backend
- [ ] Migration number taken from LEDGER.md and written back +1
- [ ] AllowSandbox = false in production
- [ ] Both notification endpoints verify signatures, and a bad signature is rejected (T8 / A4 actually run)
- [ ] Credentials never leave the server
Product
- [ ] The six prices are approved
- [ ] Decided whether FAMILY_SHARED transactions are granted (I1)
- [ ] Confirmed that cascade-deleting financial records on account deletion is acceptable
10.2 What I did not decide for you
- The exact price points โ the table gives target anchors; the real tier has to be picked from each store's price matrix, in the console.
- Whether the products already exist in ASC / Play โ this decides whether the re-anchor is editing numbers or creating and retiring products.
- Whether family-shared purchases grant Sparks โ the code does not check today.
- Whether financial ledger rows should survive account deletion โ currently
ON DELETE CASCADE. The behaviour is confirmed; whether to change it is a legal and finance call.
11. Refunds: where the money is actually recovered from (added 2026-09-22, from Leong's questions)
This section came out of a question-and-answer session in the workroom on the morning of 09-22. It is not design โ it is what the code does today, checked line by line. Refunds used to live in two rows of the ยง7 table, and the real hole was in neither of them.
11.1 One refund has to be recovered from two places, not one
The rule in one line: take it back from whoever is holding it now.
- The part the user has not spent โ clawed back from
wallets. Already done (Refund()). - The part the user has spent โ it did not vanish, it went into the recipient's ledger. โ Nothing is recovered today.
- Our own cut โ never left us, nothing to recover.
Those three add up to the whole amount. Every Spark is either still in the wallet, sitting in some creator's ledger, or retained by us. So recovering from both sides does not double-count โ worth writing down, because it looks like it would.
Walked through with real numbers (smallest tier, US$0.99 = 500 Sparks):
| Moment | User wallet | Creator ledger | Us |
|---|---|---|---|
| Top-up credited | +500 | โ | receives US$0.69 (Apple takes 30%) |
| Tips a music creator with all of it | โ500 โ 0 | +250 (MusicCreatorPct = 50) |
keeps 250 |
| Apple refunds | balance is already 0, nothing to take | +250 untouched โ ๏ธ | US$0.69 pulled back by Apple |
Result: we are out US$0.69 and still owe the creator 250 Sparks. Money in once, out twice.
โ ๏ธ The games side is identical โ do not read this as a music-only problem.
shop/repository.go:186 writes the developer's 20% (GameDevPct = 20) into dev_ledger on an
item purchase, exactly the same way. Both third-party paths leak. The only safe spend is
buying an AI assistant โ that money stays with us, and the assistant/ package contains no
dev_ledger reference at all.
11.2 Why the code does not do it โ it only opens three tables
The whole Refund() transaction (iap/repository.go) touches three tables:
iap_transactions โ marks status='refunded'
wallets โ claws the balance back
wallet_ledger โ writes a negative ledger line
dev_ledger is not among them. This is not a faulty condition โ the path simply never goes there.
11.3 The terms already say so, so this is not a pending product decision
This belongs first, because it closes what would otherwise be a debate about who should bear the loss:
- Developer Agreement ยง6 (
deploy/www/developer-terms.html:47) defines the 20% as a share of net revenue โ spelled out as "after app-store commissions, payment processing, refunds, chargebacks, and taxes". The same section at:51adds a Clawback clause: earnings from fraud, self-dealing or manipulated transactions are void and may be deducted from future payouts. - Creator Terms ยง4 (
creator-terms.html:35) grants 50% of net tip value, and explicitly inherits the Developer Agreement ยง6 payout rules, clawback included.
So "should the creator's share come back after a refund" is already answered, and the answer is yes. The gap is not policy. It is code that does not execute terms we have already signed: the contract says net, the code pays gross.
11.4 Recovery only counts if it is still collectible โ the hold
A clawback only works while the money has not left yet. Once a creator has requested a payout and received a bank transfer, there is nothing left to deduct.
How others solve it: TikTok reviews a withdrawal request for up to 15 calendar days before releasing payment (their Cash Award Withdrawal Terms), and their Rewards Policy carries the clause that matches our case exactly โ when a user is entitled to a refund, the payment the creator withdrew for those gifts is forfeited; a separate clause allows deducting Diamonds with reasonable notice. Those 15 days are not bureaucracy โ they are what makes the second clause enforceable.
Where we stand today: payouts are request-based and paid by hand
(developer.go:2550 requestPayout, minimum 1000), so a delay exists in practice, but it is
written down nowhere and enforced by no code. It relies on nobody having asked yet.
That is not a mechanism.
โ ๏ธ Related, and easy to underestimate: Apple's refund window is longer than people assume. A refund can arrive long after the purchase, so "credited today, withdrawable today" is unsound by design, not merely risky.
11.5 Apple's CONSUMPTION_REQUEST โ we do not answer it
When a user disputes a consumable, Apple asks us for consumption data and allows 12 hours.
The type is named in a comment at verify.go:202, but the switch in handler.go has
only the REFUND and REVOKE branches. It is not handled.
Not answering does not produce an error. It means Apple decides the dispute without our side of the evidence, which generally does not go our way. This is the cheapest item in this section: one handler.
11.6 Refund abuse is invisible to everyone
This one surfaced while checking the above, and it may be worth more than the clawback itself:
The iap_transactions.status='refunded' marker has exactly one reader in the entire repository โ
its own idempotency check at repository.go:104, guarding against Apple resending the same
notification. No risk logic reads it, and neither the iap nor the wallet package contains
any suspension or restriction function.
So this chain is wide open today: buy โ spend โ refund โ repeat, with nothing anywhere making a sound. Clawback addresses this transaction; a refund record somebody actually reads addresses the next one.
For contrast, TikTok's Virtual Items Policy states plainly that if a payment fails, is returned or remains unpaid, they may suspend the account or cut off access to virtual items until it is settled. That is their backstop. We have none.
11.7 So the work is these items (with dependencies)
| # | What | Phase | Depends on |
|---|---|---|---|
| R1 | In Refund(), recover the recipient's side too: reverse dev_ledger along the original split, and net any shortfall against future earnings (already authorised by terms ยง6) |
Phase 3 | none |
| R2 | Add a hold to payouts: a configurable window between request and disbursement | Phase 3 | needs a number of days (see 11.8) |
| R3 | Add a CONSUMPTION_REQUEST handler that answers within 12 hours |
Phase 3 | none |
| R4 | Feed refund records into risk: repeat refunds must be findable and must make a sound | Phase 3 | none |
| R5 | Give :short a reader (an admin row, or a periodic reconciliation) โ ยง7 already records this; folded in here as one batch |
Phase 3 | none |
| R6 | The Android refund path (RTDN + Voided Purchases) ships in the same pass as Android billing, not after it | Phase 4 | Android billing |
โ ๏ธ R6 deserves its own reason: refunds are always the half that gets deferred, and on Android it is the only path that loses money silently โ with no RTDN listener, an Android refund simply leaves the Sparks in the wallet forever and nobody ever finds out.
11.8 What I have not decided for him in this section
- How many days the hold should be. TikTok uses up to 15 calendar days. Ours depends on how long we are willing to make creators wait โ a product trade-off, not a technical one.
- Whether repeat refunds should cost account access. TikTok's terms reserve that. We have nothing today, and whether we should is a product decision.
- What happens when the creator did nothing wrong. The terms permit deducting from future earnings, but between "deduct" and "we absorb it" there is a middle setting โ do not chase amounts below some threshold. Nobody has drawn that line.
11.9 Current exposure: zero
Production iap_transactions has zero rows โ nobody has ever bought, so nobody has ever been
refunded, and not one line above has ever actually run.
That cuts both ways: it means this is not urgent, and it means this is the cheapest it will ever be to fix โ no historical data to migrate, no money already paid out to chase, and no user who would notice.
Appendix: how this relates to the existing docs
docs/IAP_GREEN_ENERGY.mdโ the design rationale for the Apple side; still valid. This document is its two-platform sequel.docs/GREEN_ENERGY_ASBUILT.mdโ as-built; โ ๏ธ several of its line references have drifted. Everything above was re-checked against current code.docs/wallet.mdโ the wallet in full, plus the decision ledger.backend/migrations/LEDGER.mdโ where migration numbers come from.
When you actually build this, the code is the source of truth โ not any document, including this one.