AGENT SKILL

premium-purchase

Builds and checks each game's one Premium purchase - expo-iap behind PurchasePort, S12 state reducer, save-before-finish, pending, restore, refund revocation, store price, StoreKit harness, App Store and Play products. Use when touching IAP, buy, restore or Premium state. Not for ads (admob-ads).

License 许可
MIT
最近更新
2026-11-05
Stars
★ 0
Forks
0

01 前言 Front Matter

name
premium-purchase
description
Builds and checks each game's one Premium purchase - expo-iap behind PurchasePort, S12 state reducer, save-before-finish, pending, restore, refund revocation, store price, StoreKit harness, App Store and Play products. Use when touching IAP, buy, restore or Premium state. Not for ads (admob-ads).

02 资源文件 226

03 SKILL.md 正文

Premium purchase

Makes each game sell its single non-consumable "Premium" honestly and with no server: StoreKit 2 verifies on the phone, Premium is saved before a transaction is finished, only explicit evidence ever revokes it, every S12 state is reachable and tested, and two scripts prove it on the real code.

Rules that must hold

  1. Save Premium before finishing: persistPremium({ isPremium: true }) (synchronous) -> dispatch premium-granted -> finish(tx). Never finish first, never auto-finish, and never finish when persistPremium returns false (the change did not reach the disk: save.update answered false for a refused write, a rejected document, a pending quarantine copy or a newer app's read-only save). On Android finishTransaction({ purchase, isConsumable: false }) is the acknowledge, called only from the port's finish() and never with isConsumable: true (that consumes Premium); an unacknowledged purchase is never delivered again and Google refunds it after 3 days, so recheckPremium and restorePremium finish every owned transaction marked isUnfinished (the adapter's isAcknowledgedAndroid === false) once persistPremium returned true. Why: StoreKit re-delivers unfinished transactions; a finished-but-unsaved purchase is lost for good, and an unacknowledged Android one is refunded.
  2. Revoke only on explicit evidence: a verified Premium transaction with a revocation date and no newer valid one. Absence, errors and offline keep the cached Premium; a revocation is saved with its revokedAtMs. On Android a refund leaves no dated proof on the phone (it only vanishes from the query), so absence never revokes there (owner default a). Why: a fresh purchase can be briefly missing from StoreKit, and the save guard ignores a revoke without a date.
  3. Listen first: startPremium subscribes to purchase events before initConnection, then loads the price, then re-checks silently (Transaction.all, onlyIncludeActiveItemsIOS: false). Why: StoreKit replays unfinished and approved Ask-to-Buy transactions right after connecting, and only Transaction.all shows refunds.
  4. Every handler is idempotent; a failure counts only while a purchase is in flight. Why: iOS reports one failure twice (rejection and listener) and re-delivers owned purchases on relaunch.
  5. Restore distinguishes "couldn't restore" from "nothing to restore": a failed sync (cancelled Apple Account prompt, offline) is always restore-failed; restore-empty only after a successful sync with no evidence. On Android restorePurchases is the Play query itself: billing unavailable, no Play Store or offline is restore-failed, never a query error swallowed into []. Why: telling a paying player "no purchase found" because the network failed is the worst answer.
  6. expo-iap is exactly 5.8.0, imported only by expo-iap-purchase-adapter.ts, and its plugin entry is the bare string 'expo-iap'. On Android it runs Play Billing 9.1.0 and needs ['expo-build-properties', { android: { kotlinVersion: '2.1.20' } }] in the same plugin list; the adapter keeps state: purchase.purchaseState, so a pending Play purchase is never granted or acknowledged. Never kitApi, KitApiError, verifyPurchaseWithProvider, verifyPurchase, useIAP, andDangerouslyFinishTransactionAutomatically, any plugin option, ios.onside.enabled or EXPO_IAP_ONSIDE. Why: they add a server (IAPKit) or the OnsideKit pod (spec N2/N3), or hide the finishing logic.
  7. One product per game: <bundleId>.premium (io.applander.<game id without hyphens>.premium, owner decision O4), NON_CONSUMABLE, the EUR 1.99 App Store price point (O2), familySharable: false (O3: Family Sharing stays off, never turned on). Every StoreKit configuration (the template and each generated *.storekit) and the ASC payload say false; create-premium-iap.ts targets exactly 1.99. On Google Play the same id is a one-time (managed) product at a EUR 1.99 default price with Google's local prices (owner step GP6, after the first internal upload). Why: spec N7; the owner decided the price and Family Sharing on 2026-09-30; product IDs can never be reused, and Family Sharing cannot be turned off once on.
  8. An empty product list, a failed connection or being offline is "store unavailable", never an error dialog; the port is withConnectivity(createExpoIapPurchaseAdapter(), connectivity.isOnline), and a connectivity change reloads the store when shouldReloadStore says so. Apple's error texts are never shown. Why: StoreKit returns [] instead of throwing; S12 has its own catalog messages; and the debug "Simulate offline" switch (Tier 2 flow 07) only reaches the store through ConnectivityPort.
  9. Show the price the store reports, in the player's digits (formatStorePrice, fallback displayPrice). Never type a price into code or catalogs; catalogs use {priceText}. The EUR 1.99 point is what App Store Connect sells in Germany; players elsewhere see their own territory's price. Why: spec S12 and Apple's per-territory price points.
  10. Every S12 state is designed and tested: reducer tests for each (Tier 1), the StoreKit harness before each release and after every expo-iap upgrade (Tier 2), and the owner's TestFlight run per game (Tier 3). Why: spec S12/15.5; the real purchase sheet only exists on TestFlight.
  11. All S12 text comes from the Shell catalogs in en, de, fa and ckb. Why: App Store Connect has no Persian or Sorani, and its texts are never shown in the app.
  12. The StoreKit harness exists only in throwaway harness builds; get-task-allow, *.storekit and *.xctest never ship. Why: get-task-allow in a store build is a security hole and a rejection.
  13. Premium changes only through the reducer, after the save: purchases, restores and re-checks via the service; the test build's debug switch (premium=0|1) via debug-premium-set, only from test-only code. Why: one path keeps ads, the save and S12 in step, and a stray unlock would give Premium away.
  14. Premium is never pushed with pop-ups: Home button (hidden once owned), Settings row, at most one Result-screen line per day (premiumNudgePrice against the save's upsell.lastShownOn). Why: spec S12 rules.
  15. The owner does the App Store Connect human steps (agreements, the app record with its fixed id, attaching Premium to the first version, the TestFlight test); the price (EUR 1.99) and Family Sharing (off) are decided, so there is no price or Family Sharing question. Ask in plain words and wait; never submit anything yourself, and stop only if Apple no longer offers EUR 1.99.

Workflow

  1. Read references/store-and-adapter.md. Install npm install -E expo-iap@5.8.0 in every app (Shell step 5, with the services). The bare string 'expo-iap' (no options) is a line of the Shell's one plugin list (Shell step 8), shellPlugins in packages/shell/src/config/shell-plugins.ts (architecture-and-boundaries ships it); never add a second list. Set premium.productId to <bundleId>.premium in each game.config.ts.
  2. At Shell step 5 copy the templates at the same relative paths: templates/packages/shell/src/services/purchase/, templates/packages/shell/src/stores/premium/, templates/__mocks__/expo-iap.ts (each test lands with its file; they need only step 5's prerequisites: zustand, testing/create-test-save.ts, testing/flush-microtasks.ts and app/stores-context.tsx). Keep existing packages/shell/src/app/stores-context.tsx and the save service; the store needs both.
  3. Read references/premium-states.md. The composition root is game-host-integration's template set (packages/shell/src/app/, Shell step 7): it builds the port where the adapter is created (withConnectivity(createExpoIapPurchaseAdapter(), isOnline) in device-adapters.ts), the dependencies once (create-premium-deps.ts), calls startPremium (never awaited) and right after it connectPremiumReloads(connectivity, stores, premiumDeps) (connect-premium-reloads.ts: loadStore when shouldReloadStore(flow, isOnline) on a connectivity change, recheckPremium when the app comes back to the foreground online). startPremium runs before the first network state arrives, so without the reloads Settings and S12 never show a price after an offline start. Wire buyPremium and restorePremium on the S12 buttons, and render S12 from premiumView and premiumNotice with the test IDs and copy keys in its tables. examples/wire-premium.md shows each call in place. The S12 layout comes from its Toybox design (hand off to toybox-screens), and every state screen must then match its design screenshot (toybox-visual-parity).
  4. Make sure the four Shell catalogs hold every Premium key from the copy deck (the i18n work owns the texts), with {priceText} in the price keys.
  5. Run Tier 1: npx jest packages/shell/src/stores/premium packages/shell/src/services/purchase packages/shell/src/app/connect-premium-reloads.test.ts --ci --selectProjects unit --coverage --collectCoverageFrom='packages/shell/src/{stores/premium,services/purchase}/**/*.ts' --coverageThreshold='{}' (paths first; only npm run test:coverage judges the thresholds).
  6. Run node ${CLAUDE_SKILL_DIR}/scripts/check-premium.mjs . and node ${CLAUDE_SKILL_DIR}/scripts/check-premium-behaviour.mjs .. Fix every FAIL line (file, rule, fix) and rerun until both print RESULT: PASS. Early in the Shell build order two rules are not due yet and print SKIP lines instead (a pass): plugin-entry until packages/shell/src/config/shell-plugins.ts exists (Shell step 8) and catalog-keys until the catalog linter packages/tooling/src/i18n/verify-catalogs.ts exists (Shell step 6); once the file exists the rule is strict (missing Shell catalogs then fail). price-target and family-sharing read the ASC tooling and every StoreKit configuration once they are in the repo.
  7. Before a release (Shell step 11 for the pilot, game step 14 for a new game) and after an expo-iap upgrade: read references/testing-tiers.md, copy templates/packages/tooling/src/storekit/, templates/packages/tooling/src/e2e/maestro-args.ts (+ test, if the repo lacks e2e-maestro's copy) and templates/packages/shell/e2e/storekit/, and make sure packages/shell/e2e/subflows/debug-setup.yaml is the Shell's one copy (e2e-maestro installs the same bytes; templates/packages/shell/e2e/subflows/debug-setup.yaml is that shared file: it waits for the app's first screen before it opens the debug link, and taps only Apple's "Open in ...?" alert, never a bare 'Open'). Run the Tier 2 harness on a throwaway simulator of your own (e07-<purpose>; the harness gives Maestro the global --device <udid> and a driver port of its own for each flow, and Metro its own port) in the release-day order of testing-tiers.md: the E2E evidence run first, then the harness, then xcrun simctl delete <udid>, then npx expo prebuild --platform ios --clean before any Release or store build.
  8. For the store step (Shell step 11 for the pilot, game step 14 for a new game, with the harness) read references/app-store-connect.md: ask the owner for the human steps, copy templates/packages/tooling/src/asc/ (keep existing ASC client files) and templates/packages/tooling/src/clock/system-clock.ts if missing, and run create-premium-iap.ts <bundleId>: it sets exactly the EUR 1.99 price point with Family Sharing off, and stops (then ask the owner) only if Apple no longer offers EUR 1.99.
  9. Ask the owner for the Tier 3 TestFlight run (buy, cancel, reinstall + restore) and report the result in plain words.
  10. On Android (once the game's iOS version has shipped): read references/google-play-billing.md. Copy expo-iap-purchase-adapter.android.test.ts with the other purchase templates and premium-flow.android.test.ts with the premium store templates (both run in the unit-android Jest project), make sure shellPlugins holds ['expo-build-properties', { android: { kotlinVersion: '2.1.20' } }] next to 'expo-iap', rerun both checkers, and ask the owner for GP6 (the Play product, after the first internal upload) and the Android Tier 3 run as a license tester (buy, cancel, pending with the slow test card, reinstall from Play + restore). The StoreKit harness has no Android counterpart; an emulator or adb install build correctly shows "store unavailable".

Definition of done

  • [ ] expo-iap is exactly 5.8.0, imported only by the adapter; the plugin entry is the bare string; no banned API, option or Onside switch anywhere.
  • [ ] Every template file is in place and Tier 1 passes (reducer, notice, nudge, evidence, service, connectivity gate, price, ASC payloads).
  • [ ] A purchase saves Premium, updates ads at once, then finishes; pending shows "Waiting for approval" and an approval later turns Premium on without a tap.
  • [ ] Restore shows restore-failed after a failed sync and restore-empty only after a successful one; refunds revoke only with a revocation date.
  • [ ] Offline (and with the debug offline switch) S12 shows "store unavailable" without asking StoreKit: the port is wrapped in withConnectivity where the adapter is created; after an offline start the price loads once the network comes (connectPremiumReloads after startPremium).
  • [ ] Prices come from the store in the player's digits; S12 texts exist in all four catalogs; every S12 state has its test ID.
  • [ ] Every S12 state screen has passed the Toybox visual-parity check against its design screenshot.
  • [ ] Before a release: the seven Tier 2 scenarios pass on a throwaway simulator in the release-day order (E2E, harness, delete the simulator, clean prebuild); the store IPA has no *.storekit, *.xctest or get-task-allow; the owner's Tier 3 run passed for this game and version.
  • [ ] Premium is <bundleId>.premium at the EUR 1.99 price point with Family Sharing off in every StoreKit configuration and ASC payload (price-target, family-sharing).
  • [ ] The product exists in App Store Connect (en-US/de-DE texts, EUR 1.99, Family Sharing off, availability, review screenshot) and is attached to the first version.
  • [ ] Android: Premium is saved before the purchase is acknowledged and never consumed, a pending Play purchase is neither granted nor acknowledged, the launch re-check acknowledges a saved but unacknowledged purchase, absence never revokes (owner default a), Kotlin 2.1.20 is set through expo-build-properties, and expo-iap-purchase-adapter.android.test.ts and premium-flow.android.test.ts pass in unit-android.
  • [ ] node ${CLAUDE_SKILL_DIR}/scripts/check-premium.mjs . and node ${CLAUDE_SKILL_DIR}/scripts/check-premium-behaviour.mjs . both print RESULT: PASS

Anti-patterns

  • Finishing the transaction first "so StoreKit stops re-sending it". Re-delivery is the safety net; save, grant, then finish.
  • Turning Premium off because the re-check found nothing. Absence is not evidence; only a revocation date is.
  • Showing "No purchase to restore" when restorePurchases threw. That is restore-failed.
  • useIAP or verifyPurchase "for simplicity". They hide finishing or call a server; the adapter template is the whole integration.
  • Giving the plugin { iapkitApiKey } or any options object. Even an empty object fails the audit; use 'expo-iap'.
  • Typing "€1.99" in a catalog, a test fixture of the screen, or a component. Use {priceText} and formatStorePrice.
  • Reading getAvailablePurchases() with the default active-only list. Refunds disappear from it; pass onlyIncludeActiveItemsIOS: false.
  • launchApp: { clearState: true } in a Tier 2 flow. Uninstalling clears the test transactions.
  • A short wait in a relaunch flow. StoreKit answers the launch re-check only after its transaction sync (28 s once after a refund); the refund and approval relaunch flows wait 60 s for their state (harness-relaunch-wait). Never shorten it to make a run faster.
  • Handing the raw expo-iap adapter to the service. Without withConnectivity the store ignores ConnectivityPort: offline S12 waits on StoreKit and Tier 2 flow 07 (offline=1) shows a price instead of "store unavailable".
  • Putting a StoreKit flow under e2e/flows/. The normal E2E run would run it against an unarmed build.
  • Pushing Premium with a pop-up after a loss or on launch. Only the three quiet entry points.
  • Editing a Jest test or a fixture until the checker passes. The behaviour check runs the real modules; fix the module.

Files in this skill

File What it is Read/run when
references/store-and-adapter.md Premium rules, the three StoreKit facts, versions, bans, PurchasePort, adapter facts, error-code table, re-verify Workflow step 1, and before touching the adapter
references/premium-states.md Reducer and actions, the S12 state table (views, test IDs, copy keys), restore toasts, service order, wiring, edge cases, price display Workflow step 3, and on any state change
references/testing-tiers.md Tier 1 tests, the verified StoreKit harness, its files, scenarios, commands and gotchas, Tier 3 steps Workflow steps 5 and 7
references/app-store-connect.md Product rules, the EUR 1.99 price (O2), Family Sharing off (O3), the ASC API script, remaining steps, human steps Workflow step 8
references/google-play-billing.md Android: Play Billing 9.1.0 and Kotlin 2.1.20, acknowledge after the save, the 3-day refund of unacknowledged purchases, pending, restore, refunds (absence never revokes, owner default a), the GP6 product at EUR 1.99, license testers, Play-installed builds Workflow step 10, and before touching Android purchase code
examples/wire-premium.md The complete wiring for the pilot game Workflow step 3, as the model
templates/packages/shell/src/services/purchase/ Port, expo-iap adapter, fake, connectivity gate withConnectivity (+ test), service (+ test; the test is synced from the library, the same file as unit-and-component-tests' example: do not edit here), store flow with shouldReloadStore, the Android adapter test expo-iap-purchase-adapter.android.test.ts (the unit-android project), price formatter (+ test) Workflow step 2
templates/packages/shell/src/stores/premium/ State, transitions, reducer (+ test), view, notice (+ test), Result-screen nudge (+ test), evidence (+ test), Zustand store, and premium-flow.android.test.ts (the unit-android project: listening before initConnection, the launch re-check acknowledging a saved but unacknowledged purchase, offline keeping the cached Premium) Workflow step 2
templates/__mocks__/expo-iap.ts Jest root mock (real ErrorCode, no banned APIs) Workflow step 2
templates/packages/tooling/src/storekit/ Premium.storekit.template (EUR 1.99, Family Sharing off), storekit-harness.entitlements, ArmTests.swift, add-harness.rb, storekit-harness.ts (explicit --device, its own driver port for each flow, its own Metro port), metro-session.ts with its test (Metro's output in build/logs/storekit-metro.log, Metro stopped however the run ends), busy-note.ts with its test (the log line that names a busy Mac) Workflow step 7
templates/packages/tooling/src/e2e/ maestro-args.ts: the shared Maestro target helper (maestroGlobalArgs, freeDriverPort, driverPortFor) with its test; e2e-maestro's file, synced from the library (do not edit here) Workflow step 7, with the harness
templates/packages/shell/e2e/storekit/ The seven Tier 2 Maestro flows (the two relaunch flows wait 60 s for StoreKit's launch re-check) Workflow step 7
templates/packages/shell/e2e/subflows/debug-setup.yaml The debug deep-link subflow the flows call (the Shell's one shared copy, synced from the library: it waits for the first screen before the link and taps only Apple's "Open in ...?" alert; do not edit here) Workflow step 7
templates/packages/tooling/src/asc/ ASC payloads (+ test), create-premium-iap.ts (the CLI with TARGET_EUR) over premium-iap-setup.ts (+ test on a fake App Store Connect: the bundle id checked with ios-simulator-build's isAppId before the key is read, exit 2 with the usage; the app found by its exact bundle id), find-app.ts (+ test; synced from the library, identical to ios-release-testflight's copy), JWT, credentials and client Workflow step 8
templates/packages/tooling/src/clock/system-clock.ts The tooling wall clock with nowEpochSeconds and todayIso Workflow step 8, if missing or lacking nowEpochSeconds
assets/premium-facts.json Pinned version, adapter allowlist, banned APIs and config words, catalog keys, S12 test IDs, views, required files, Family Sharing decision Read by the checkers; update on an upgrade or an owner decision
scripts/check-premium.mjs Static checks: files, pin, imports, banned APIs, plugin options, adapter facts, the adapter wrapped in withConnectivity where it is created, the debug Premium action only in test-only code, product IDs, typed prices (the parity harness's fixture store excepted), catalog keys, S12 test IDs, harness isolation (the repo scan skips skills/, .claude/ and generated folders), StoreKit config, Family Sharing, the EUR 1.99 target and every StoreKit displayPrice (price-target) and Family Sharing off in the StoreKit template, every generated apps/<id>/ios/*.storekit and the ASC payload (family-sharing), the StoreKit harness's Maestro calls through maestroGlobalArgs, never --udid, each flow on its own driver port (harness-maestro), and the 60 s wait of the two relaunch flows (harness-relaunch-wait); on Android the acknowledge only in finish() and never consumed (android-acknowledge), state: purchase.purchaseState (android-pending), Kotlin through expo-build-properties (android-kotlin) Workflow step 6
scripts/check-premium-behaviour.mjs Runs the repo's Premium modules (type stripping) against the S12 table, evidence, service order, restore, revocation, offline gate, Result-screen nudge and price rules, the expo-iap adapter against a scripted expo-iap (error codes, restore, Transaction.all mapping, call shapes), the composition root's two store reloads (store-reloads), and Google Play Billing through the real adapter with Play's purchase shape (android-pending, android-restore, android-refund) Workflow step 6
scripts/lib/repo-scan.mjs File listing and import parsing helpers Read only to change a checker
scripts/lib/load-ts.mjs Imports repo TypeScript modules with the @e07/* aliases and the module stand-ins Read only to change a checker
scripts/lib/stubs/expo-iap.mjs Scripted Node stand-in for expo-iap 5.8.0 used by the behaviour check (update ErrorCode on an upgrade) Read only to change a checker
scripts/lib/assemble-fixtures.mjs Builds each self-test case from templates + base repo + mutation.json Read only to add a self-test case
scripts/selftest.mjs Proves both checkers on the clean repo and 67 planted problems (the Android acknowledge, pending, Kotlin, restore and refund cases included) After changing a checker, a template or a fixture
scripts/check-lib.mjs Shared script helper, synced from the library (do not edit here) Never by hand
assets/shared.json Declares the shared files copied into this skill When adding a shared file
tests/fixtures/ base-repo/ plus one mutation.json and EXPECT.txt per case, per checker When adding a rule

Related skills

  • admob-ads - Premium turns ads off and perks free; it reads isPremium.
  • state-stores - the store pattern and createShellStores, which creates the Premium store.
  • save-persistence-and-migrations - the save's premium section and its revoke guard.
  • toybox-screens - the S12 layout that renders the states and toasts.
  • toybox-visual-parity - proves each S12 state screen matches its design screenshot.
  • i18n-strings-and-catalogs - the Premium texts in four languages.
  • privacy-and-network-audit - the network audit that fails on IAPKit or Onside.
  • ios-release-testflight - signing, the store-artifact gate and the TestFlight human steps.
  • e2e-maestro - Maestro installation and the debug deep link.

04 同仓库更多 Skills

zakariaf
zakariaf/pocket-arcade

unit-and-component-tests

Sets up Jest and writes Pocket Arcade test code - three projects, RNTL 14 async via renderWithShell, root SDK mocks, fakes, fast-check, coverage, Stryker. Use when a test needs the Shell wrapper, a mock or a fake, or Jest fails. Not for the red-green loop (tdd-workflow) or goldens (golden-tests).

★ 0
zakariaf
zakariaf/pocket-arcade

typescript-and-lint-rules

Enforces Pocket Arcade's strict TypeScript 6 tsconfigs, ESLint 9 flat config, Prettier and size limits. Use when writing or splitting .ts/.tsx or fixing a tsc or lint error. Not for gate wiring (quality-gates), names (naming-conventions) or import zones (architecture-and-boundaries).

★ 0
zakariaf
zakariaf/pocket-arcade

demo-skill

Builds a demo. Use when testing the pitfall checker.

★ 0
zakariaf
zakariaf/pocket-arcade

demo-skill

Builds a demo. Use when testing the pitfall checker.

★ 0
zakariaf
zakariaf/pocket-arcade

troubleshooting-playbook

Diagnoses and fixes Pocket Arcade failures with a catalogue of 300+ known ones (symptom, cause, fix). Use when something errors, crashes, hangs or renders wrong, or an error or log needs explaining. Not for gate setup (quality-gates) or running builds (ios-simulator-build, ios-release-testflight).

★ 0
zakariaf
zakariaf/pocket-arcade

toybox-visual-parity

Proves a built Pocket Arcade screen matches its Toybox design screenshot (light/dark, en/fa) and drives the fixes: capture, element gates, sheets, sign-off. Use when any screen is built or changed, or for visual parity, design match, screenshot compare. Not for building screens (toybox-screens).

★ 0