Sells one non-consumable "Premium" honestly, on iOS and Android, with no server by default: the store verifies on the device, Premium is saved before a transaction is finished, only explicit evidence ever revokes it, every paywall state is reachable and tested, and two scripts prove it on the app's own code.
Rules that must hold
- Save Premium before finishing:
premium.getState().grantPremium(...)(synchronous, written to the entitlements table) returns ok -> the paywall'sfinish-purchase->port.finish(tx). Never finish first, never auto-finish, and never finish when the grant failed: the paywall showssave-failedand the transaction stays open. The re-check and the restore finish every owned Android purchase markedisUnfinishedonce its grant returned ok. Why: StoreKit re-delivers an unfinished transaction at the next launch, so the save is retried; a finished but unsaved purchase is lost for good, and Google refunds a purchase left unacknowledged for 3 days. - Revoke only on explicit evidence: a verified transaction with a revocation date and no newer
valid one, saved with that date (
revokePremium(revokedAtMs); the reducer ignores a revoke without a positive date). Absence, read errors and offline keep the saved Premium. Why: a fresh purchase can be briefly missing from the store's list. On Android a refund only removes the purchase from the list (no revocation date), so without a server Premium stays after an Android refund: that is the honest default (see the server profile in references/store-and-adapter.md). - Listen first:
startPremiumsubscribes to purchase events before it connects, then loads the price, then re-checks silently throughgetAvailablePurchases({ onlyIncludeActiveItemsIOS: false })(iOSTransaction.all). Why: the store replays unfinished and approved Ask to Buy transactions right after connecting, and onlyTransaction.allstill shows refunds. - Every handler is idempotent; a failure counts only while purchasing; BUY asks the store only when the paywall accepted the tap. Why: iOS reports one failure twice (the rejection and the listener) and re-delivers owned purchases on relaunch; a refused tap must not open a sheet.
- Restore tells "couldn't restore" from "nothing to restore": any failed sync or read is
restore-failed;restore-emptyonly after a successful sync with no evidence, never for someone who owns Premium; a restore whose save failed issave-failed. Why: telling a payer "no purchase found" because the network failed is the worst answer. expo-iapis pinned exactly (5.8.0, the stack table), imported only bysrc/services/purchase/expo-iap-purchase-adapter.tswith its allowlist, and the plugin entry is the bare string'expo-iap'. NouseIAP, no auto-finishing, ever; no server API (kitApi,KitApiError,verifyPurchase,verifyPurchaseWithProvider), plugin option or store switch (Onside, Horizon, Fire OS, Vega, alternative billing, theirEXPO_IAP_*variables) unlesscontracts/in-app-purchases.jsonopts in. Why: they add a server or another billing SDK, or hide the finishing order.- Every store call resolves with a value or a
StoreFailure; none rejects. The adapter wraps each vendor call inattemptAsync; the service reads.ok. Why: a rejection escaping the adapter skips the order above (error-handling-typed-results' adapter boundary). - One non-consumable product id, written once: the contract's
productId(naming-conventions' product-id grammar, valid on both stores), thePREMIUM_PRODUCT_IDconstant and every StoreKit configuration agree. Price point, base territory and Family Sharing are the developer's decisions in the contract (Family Sharing off by default: once on it can never be turned off); the StoreKit file and the App Store Connect script read them, never a typed value. Why: product ids can never be reused, and a second copy of a decision drifts. - An empty product list, a failed connection or offline is "store unavailable", never an error
dialog. The port is
withConnectivity(createExpoIapPurchaseAdapter(), connectivity.isOnline)where it is created;connectPremiumReloadsreloads the store on a connectivity change (and re-checks once back online) and re-checks on every return to the foreground. Store error texts are never shown. Why: StoreKit returns[]instead of throwing,startPremiumruns before the first network report, and a simulated-offline switch reaches the store only through ConnectivityPort. - Show the store's own
displayPrice, unchanged (storePriceText; no text means "store unavailable"); catalogs use{priceText}; never type a price into code or catalogs, never rebuild it from the numericprice. Why: every storefront has its own price point and currency, and it is the text the purchase sheet shows (value-objects-money-and-units states the same rule). - Every paywall state is designed and tested: a view value, a test ID from the contract and a reducer case each (Tier 1), the StoreKit harness before a release and after an expo-iap upgrade (Tier 2), and a human run on TestFlight and a Play testing track (Tier 3). Why: the real purchase sheets exist only in store-signed builds.
- All paywall text comes from the app's catalogs, in every locale. Store-console product texts are never shown in the app. Why: the consoles do not cover every app locale.
- Premium has one writer: the premium store (persist before publish, state-management-zustand),
called only by the purchase service and test-only code; only the service calls the purchase port;
the paywall flow is a memory-only store; everything else reads
selectIsPremium(ads pass it to AdsProvider asisPremium, admob-ads-and-consent). The entitlements table is never in a data backup or restore (data-export-and-restore excludes it); a new device gets Premium back through Restore. Why: one path keeps ads, Settings and the paywall in step, a stray grant gives Premium away, and an editable backup file would grant it too (entitlement-writers,port-callers). - The StoreKit harness exists only in throwaway harness builds:
get-task-allow,*.storekitand*.xctestnever ship, StoreKit test code never enterssrc/, and the StoreKit flows live in.maestro/storekit/, outside the normal E2E folders. Why:get-task-allowin a store build is a rejection, and an unarmed build would fail those flows. - Harness runs name their device and ports, and relaunch flows wait 60 s: every Maestro call
goes through
maestroGlobalArgs(the simulator's UDID and its own driver port), Metro gets its own port, never--udidalone. Why: other sessions share the Mac, and StoreKit answers the launch re-check only after its transaction-cache sync (28 s measured). - Premium is never pushed with pop-ups: quiet, declared entry points (a paywall route, a
Settings row); an in-context upsell line shows at most once per local day, never to an owner
(
upsellPrice); Restore is reachable from the paywall and Settings. Why: App Review requires a restore mechanism, and a pop-up after a failure or a line on every screen reads as a trap. - Humans do the store-console steps. Agreements, tax and banking, the app record, the price and Family Sharing decisions, attaching the first purchase to a version, and the Tier 3 runs: ask in plain words and wait. Never submit, never print an API key or a JWT.
Workflow
- Read references/store-and-adapter.md. From the repo root run
npm install -E expo-iap@5.8.0(verified on 2026-10-10; re-verify withnpm view expo-iap version) and add the bare'expo-iap'to theapp.config.tsplugins. Copytemplates/contracts/and fill in the developer's decisions (ask for the price point, territory and Family Sharing; never guess): replace__PRODUCT_ID__and__PRICE__in the contract first, then the same values insrc/config/purchase-products.ts(step 2) andscripts/storekit/Premium.storekit(step 6). - Copy the code templates at the paths their first lines name:
templates/src/services/purchase/,templates/src/stores/premium/,templates/src/stores/paywall/,templates/src/data/entitlements/,templates/src/config/purchase-products.ts,templates/test/,templates/__mocks__/expo-iap.ts. Prerequisites:src/core/result.ts, the data layer with theentitlementstable (persistence-and-migrations), ConnectivityPort (networking-and-server-state). Addentitlements: createSqliteEntitlementRepository(...)toAppDataand keepentitlementsincontracts/persistence.jsonprotectedTables. The premium store persists, so an app whose only persisting store was settings now needs state-management-zustand'scontracts/stores.json: add it when missing. Copytemplates/src/stores/premium/upsell-line.ts(+ test) only when the app shows an in-context upsell. - Read references/paywall-states.md. Add
connectivityand the gatedpurchaseport to the composition root as in examples/create-device-services.ts (fakes increate-test-services.ts), then render the startup piece of examples/purchase-root.tsx inside AppRoot:openPurchaseStoresin auseStateinitializer,startPremiumafter the first frame (never awaited, its unsubscribe dropped even when the effect was cleaned up first),connectPremiumReloadsright after it,PurchaseStoresProvideraround the root stack, as one wrapper so AppRoot stays withinreact/jsx-max-depth. Build the paywall screen with screen-contracts: a thin route (src/app/(app)/paywall.tsx), a model hook readingpaywallView,paywallNoticeandselectIsPremium, and the contract's test IDs. Give every catalog the contract's keys. - Run Tier 1:
npx jest src/services/purchase src/stores/premium src/stores/paywall test/sqlite/sqlite-entitlement-repository.test.ts scripts/asc scripts/storekit --ci --maxWorkers=2. - Run
node ${CLAUDE_SKILL_DIR}/scripts/check-purchases.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-purchase-behaviour.mjs .; fix everyFAILline (each rule, contract key and expected SKIP line is in references/checks.md) and rerun until both printRESULT: PASS. Run state-management-zustand's and naming-conventions' checkers too (liststorekitincontracts/naming.jsonextraE2eFoldersand in e2e-maestro'scontracts/e2e.jsonmanualFolders, withoutstartsOutside). - Before a release and after an expo-iap upgrade, read
references/testing-tiers.md: copy
templates/scripts/storekit/andtemplates/.maestro/storekit/, withtemplates/scripts/e2e/when the repo lacks it (they also need e2e-maestro's debug sub-flow). Addpremium=0|1andopen=paywallto the debug-link registry with its test-only Premium handler, as examples/debug-premium-link.ts shows (debug-menu-and-test-hooks' template has neither). Run the harness on a throwaway simulator of your own (<project>-storekit), then the Android license-tester run, in the release-day order. - For the store consoles read references/store-consoles.md: ask the
developer for the human steps, copy
templates/scripts/asc/and runnode scripts/asc/create-premium-iap.ts <bundleId>only after they said yes; create the Play product by hand. Then ask for the Tier 3 runs and report the results in plain words.
Definition of done
-
expo-iapis exactly 5.8.0, imported only by the adapter; the plugin entry is the bare string; no banned API, option or switch outside a contract opt-in. - Every template file is in place and Tier 1 passes; the entitlements table is protected from resets.
- A purchase saves Premium, shows it everywhere at once, then finishes; a refused save shows
save-failedand finishes nothing; pending shows "waiting for approval" and an approval later turns Premium on without a tap. - Restore shows
restore-failedafter a failed sync andrestore-emptyonly after a successful one; refunds revoke only with a date. - Offline (and with a simulated-offline switch) the paywall shows "store unavailable" without asking the store; after an offline start the price loads once the network comes.
- Prices come from the store; paywall texts exist in every catalog; every paywall state has its test ID.
- The contract,
PREMIUM_PRODUCT_IDand every StoreKit configuration agree on the product, price point, territory and Family Sharing. - Before a release: the seven StoreKit scenarios pass on a throwaway simulator in the release-day order, the Android license-tester run passes, the store builds carry no
*.storekit,*.xctestorget-task-allow, and the Tier 3 runs passed. -
node ${CLAUDE_SKILL_DIR}/scripts/check-purchases.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-purchase-behaviour.mjs .both printRESULT: PASS. Expected SKIP lines while adopting:src/i18n/catalogs [catalog-keys](i18n-strings-and-catalogs creates it), the paywall folder[state-testids](screen-contracts creates it),.maestro/subflows/debug-setup.yaml [harness-subflow](e2e-maestro creates it). An app that sells nothing printsNOT APPLICABLE.
Anti-patterns
- Finishing the transaction first "so the store stops re-sending it". Re-delivery is the safety net; save, show, then finish.
- Turning Premium off because the re-check found nothing. Absence is not evidence; only a dated revocation is.
- Showing "No purchase to restore" when the sync failed. That is
restore-failed. useIAPorverifyPurchase"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 check; use'expo-iap'. - Typing "$2.99" into a catalog, a component or a test of the screen. Use
{priceText}andstorePriceText. - Formatting
product.pricewithstyle: 'currency'. It can disagree with the purchase sheet; showdisplayPrice. - Reading
getAvailablePurchases()with the default active-only list. Refunds disappear from it; passonlyIncludeActiveItemsIOS: false. - A publish-only Premium store fed by a service that writes the row itself. Two writers own one fact; the store's
grantPremiumwrites, then publishes. - Handing the raw expo-iap adapter to the service. Offline the paywall then waits on the store, and a simulated-offline switch shows a price.
- Expecting a debug-variant bundle id or package to buy real products. Products belong to the store app record; test with the StoreKit file on iOS and a license tester on the release package name on Android.
launchApp: { clearState: true }or a short wait in a StoreKit relaunch flow. Uninstalling clears the test transactions, and StoreKit answers only after its sync; keep the 60 s waits.- Putting a StoreKit flow under
.maestro/flows/. The normal E2E run would run it against an unarmed build. - Editing a Jest test or a fixture until a 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 | The default model and the server profile, the StoreKit and Play Billing facts, version and plugin, bans, PurchasePort, adapter facts, error codes, re-verify, multi-package | Workflow step 1, before touching the adapter |
| references/paywall-states.md | Entitlement store and table, paywall flow and actions, the state table (views, test IDs, keys), notices, service order, wiring and routes, edge cases, price display, entry points | Workflow step 3, on any state change |
| references/testing-tiers.md | Tier 1 Jest, the StoreKit harness (facts, files, scenarios, commands, gotchas), Android license testers and Play Billing Lab, Tier 3, release-day order | Workflow steps 4 and 6 |
| references/store-consoles.md | Product rules, price, Family Sharing, the App Store Connect script and its remaining steps, Play Console, human steps | Workflow step 7 |
| references/checks.md | Every checker rule, the contract keys, config keys and expected SKIP lines | Workflow step 5, when a FAIL line needs context |
| examples/create-device-services.ts | The composition root with ConnectivityPort and the gated purchase port (the one place that creates the adapter) | Workflow step 3, as the model |
| examples/purchase-root.tsx | The startup piece AppRoot renders: stores, deps, start, reloads, provider | Workflow step 3, as the model |
| examples/debug-premium-link.ts | The debug-link registry entries (premium, open=paywall), the contract rows and the test-only Premium handler |
Workflow step 6, before the StoreKit flows |
templates/src/services/purchase/ |
Port, expo-iap adapter, fake (+ test), connectivity gate (+ test), evidence (+ test), service (+ test), store flow, reloads (+ test), storePriceText (+ test) |
Workflow step 2 |
templates/src/stores/premium/ |
Entitlement reducer, cases table (+ test), selectors, store (+ test), openPurchaseStores (+ test, the one caller of both factories), PurchaseStoresProvider (+ test), optional upsell-line.ts (+ test) |
Workflow step 2 |
templates/src/stores/paywall/ |
Paywall state, transitions (+ test), reducer, cases table (+ test), view, notice, memory-only store (+ test) | Workflow step 2 |
templates/src/data/entitlements/ |
EntitlementRepository port, the SQLite repository, the fake |
Workflow step 2 |
templates/src/config/purchase-products.ts |
PREMIUM_PRODUCT_ID, equal to the contract |
Workflow step 2 |
templates/test/ |
purchase/create-premium-test-deps.ts (service over real stores and fakes), sqlite/sqlite-entitlement-repository.test.ts; synced SQLite test drivers and flush-microtasks.ts |
Workflow steps 2 and 4 |
templates/contracts/ |
in-app-purchases.json (the developer's decisions and the paywall's keys and test IDs) and its schema |
Workflow step 1 |
templates/scripts/storekit/ |
Premium.storekit, storekit-harness.entitlements, ArmTests.swift, add-harness.rb, storekit-harness.ts, busy-note.ts (+ test) |
Workflow step 6 |
templates/.maestro/storekit/ |
The seven StoreKit Tier 2 flows | Workflow step 6 |
templates/scripts/asc/ |
Contract reader, payloads (+ test, including the exact bundle-id match), create-premium-iap.ts; the JWT (+ test), credentials and client are the canonical shared copies (ios-release-testflight owns them) |
Workflow step 7 |
templates/__mocks__/expo-iap.ts |
The canonical root mock (real ErrorCode, no server APIs), synced from the library (do not edit here) |
When the repo lacks it |
templates/src/services/connectivity/ |
The canonical ConnectivityPort, expo-network adapter, reachability hub and fake with tests (networking-and-server-state's, synced; do not edit here) | When the repo lacks them |
templates/src/core/ |
The canonical result.ts with its test (synced; do not edit here) |
When the repo lacks it |
templates/src/data/sql-driver.ts |
The canonical SqlDriver port (synced; do not edit here) | When the repo lacks it |
templates/src/services/error-log/ |
The canonical ErrorLogPort and its fake with a test (synced; do not edit here) | When the repo lacks them |
templates/scripts/clock/ |
The canonical tooling wall clock with its test (synced; do not edit here) | When the repo lacks it |
templates/scripts/e2e/ |
The canonical maestro-args.ts (Maestro device binding for the StoreKit harness) with its test (ios-simulator-build's, synced; do not edit here) |
Workflow step 6, when the repo lacks it |
assets/purchase-facts.json |
Pinned version, adapter allowlist, banned APIs and config words, harness facts | Read by the checkers; update on an upgrade |
assets/template-placeholders.json |
Sample values for __PRODUCT_ID__ and __PRICE__ |
When verifying templates |
assets/shared.json |
Declares the synced files | When adding a shared file |
scripts/check-purchases.mjs |
Static checks (21 rules + one SKIP-only) | Workflow step 5 |
scripts/check-purchase-behaviour.mjs |
Runs the app's purchase modules and the adapter against a scripted expo-iap (19 rules) | Workflow step 5 |
scripts/lib/purchase-repo.mjs |
Layout paths, adoption and NOT APPLICABLE facts, contract reader | Read only to change a checker |
scripts/lib/module-stubs.mjs |
Resolves expo-iap to the stand-in while the behaviour check imports the app |
Read only to change a checker |
scripts/lib/stubs/expo-iap.mjs |
The scripted expo-iap 5.8.0 stand-in (update ErrorCode on an upgrade) |
On an expo-iap upgrade |
scripts/lib/source-scan.mjs |
Shared import and code scanning, synced from the library (do not edit here) | Never by hand |
scripts/lib/app-modules.mjs |
Shared loader of the app's TypeScript in Node, synced from the library (do not edit here) | Never by hand |
scripts/check-lib.mjs |
Shared script helper, synced from the library (do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers on the templates and every planted bug | After changing a checker, a template or a fixture |
tests/ |
bases/app, bases/bare and the overlay fixtures of both checkers |
When adding a rule |
Related skills
admob-ads-and-consent- ads turn off for Premium; it readsselectIsPremium.state-management-zustand- the store pattern behindgrantPremiumand the paywall store.persistence-and-migrations- the entitlements table, its migration and its protection from resets.networking-and-server-state- ConnectivityPort and, for the server profile, the ApiClient port.error-handling-typed-results-Result,attemptAsyncand the adapter boundary.screen-contracts- the paywall screen that renders the state table.i18n-strings-and-catalogs- the paywall texts in every locale.naming-conventions- the product-id grammar and theextraE2eFoldersentry.debug-menu-and-test-hooks- the debug deep link the flows use; the app addspremiumandopen=paywallto it (the screen parameter isopen; its contract refusesscreen).value-objects-money-and-units- Money from the store's double, andformatMoneywhen digits follow an in-app setting.e2e-maestro- Maestro, the debug sub-flow and the device binding.ios-simulator-build- simulators andscripts/e2e/maestro-args.ts.privacy-and-store-declarations- the network and SDK audit of a release.ios-release-testflight- signing, the store-artifact gate and TestFlight.