Skip to content
Back to skills

In App Purchases

ASecurity

Sells a Premium unlock with expo-iap behind a PurchasePort - saved entitlement, paywall states, restore, refunds, store testing. Use when touching IAP, buy, restore, paywall or Premium state. Not for ads (admob-ads-and-consent).

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 18, 2026
developmenttypescriptgoswiftsqlreactnodetestinggitapi

Works with

  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 19 files and shows the line behind each finding

Scanned October 18, 2026

npx -y skills add zakariaf/ReactNative-Skills --skill in-app-purchases --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of In App Purchases?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for In App Purchases
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/zakariaf-in-app-purchases/badge)](https://www.skillsdirectory.com/skills/zakariaf-in-app-purchases)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: in-app-purchases
description: Sells a Premium unlock with expo-iap behind a PurchasePort - saved entitlement, paywall states, restore, refunds, store testing. Use when touching IAP, buy, restore, paywall or Premium state. Not for ads (admob-ads-and-consent).
---

# In-app purchases

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

1. **Save Premium before finishing:** `premium.getState().grantPremium(...)` (synchronous, written
   to the entitlements table) returns ok -> the paywall's `finish-purchase` -> `port.finish(tx)`.
   Never finish first, never auto-finish, and never finish when the grant failed: the paywall shows
   `save-failed` and the transaction stays open. The re-check and the restore finish every owned
   Android purchase marked `isUnfinished` once 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.
2. **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).
3. **Listen first:** `startPremium` subscribes to purchase events before it connects, then loads the
   price, then re-checks silently through `getAvailablePurchases({ onlyIncludeActiveItemsIOS: false })`
   (iOS `Transaction.all`). *Why:* the store replays unfinished and approved Ask to Buy transactions
   right after connecting, and only `Transaction.all` still shows refunds.
4. **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.
5. **Restore tells "couldn't restore" from "nothing to restore":** any failed sync or read is
   `restore-failed`; `restore-empty` only after a successful sync with no evidence, never for someone who owns Premium;
   a restore whose save failed is `save-failed`. *Why:* telling a payer "no purchase found" because
   the network failed is the worst answer.
6. **`expo-iap` is pinned exactly (5.8.0, the stack table), imported only by
   `src/services/purchase/expo-iap-purchase-adapter.ts` with its allowlist, and the plugin entry is
   the bare string `'expo-iap'`.** No `useIAP`, no auto-finishing, ever; no server API (`kitApi`,
   `KitApiError`, `verifyPurchase`, `verifyPurchaseWithProvider`), plugin option or store switch
   (Onside, Horizon, Fire OS, Vega, alternative billing, their `EXPO_IAP_*` variables) unless
   `contracts/in-app-purchases.json` opts in. *Why:* they add a server or another billing SDK, or hide
   the finishing order.
7. **Every store call resolves with a value or a `StoreFailure`; none rejects.** The adapter wraps each
   vendor call in `attemptAsync`; the service reads `.ok`. *Why:* a rejection escaping the adapter
   skips the order above (error-handling-typed-results' adapter boundary).
8. **One non-consumable product id, written once:** the contract's `productId` (naming-conventions'
   product-id grammar, valid on both stores), the `PREMIUM_PRODUCT_ID` constant 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.
9. **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; `connectPremiumReloads` reloads 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, `startPremium` runs before the first network
   report, and a simulated-offline switch reaches the store only through ConnectivityPort.
10. **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 numeric `price`. *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).
11. **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.
12. **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.
13. **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 as `isPremium`, 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`).
14. **The StoreKit harness exists only in throwaway harness builds:** `get-task-allow`, `*.storekit`
    and `*.xctest` never ship, StoreKit test code never enters `src/`, and the StoreKit flows live in
    `.maestro/storekit/`, outside the normal E2E folders. *Why:* `get-task-allow` in a store build is a
    rejection, and an unarmed build would fail those flows.
15. **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 `--udid` alone. *Why:* other sessions share the Mac, and StoreKit answers the launch
    re-check only after its transaction-cache sync (28 s measured).
16. **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.
17. **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

1. Read [references/store-and-adapter.md](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 with `npm view expo-iap version`)
   and add the bare `'expo-iap'` to the `app.config.ts` plugins. Copy `templates/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 in
   `src/config/purchase-products.ts` (step 2) and `scripts/storekit/Premium.storekit` (step 6).
2. 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 the `entitlements` table (persistence-and-migrations), ConnectivityPort
   (networking-and-server-state). Add `entitlements: createSqliteEntitlementRepository(...)` to
   `AppData` and keep `entitlements` in `contracts/persistence.json` `protectedTables`. The premium
   store persists, so an app whose only persisting store was settings now needs
   state-management-zustand's `contracts/stores.json`: add it when missing. Copy
   `templates/src/stores/premium/upsell-line.ts` (+ test) only when the app shows an in-context upsell.
3. Read [references/paywall-states.md](references/paywall-states.md). Add `connectivity` and the gated
   `purchase` port to the composition root as in
   [examples/create-device-services.ts](examples/create-device-services.ts) (fakes in
   `create-test-services.ts`), then render the startup piece of
   [examples/purchase-root.tsx](examples/purchase-root.tsx) inside AppRoot: `openPurchaseStores` in a
   `useState` initializer, `startPremium` after the first frame (never awaited, its unsubscribe
   dropped even when the effect was cleaned up first), `connectPremiumReloads` right after it,
   `PurchaseStoresProvider` around the root stack, as one wrapper so AppRoot stays within
   `react/jsx-max-depth`. Build the paywall screen with screen-contracts: a
   thin route (`src/app/(app)/paywall.tsx`), a model hook reading `paywallView`, `paywallNotice` and
   `selectIsPremium`, and the contract's test IDs. Give every catalog the contract's keys.
4. 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`.
5. Run `node ${CLAUDE_SKILL_DIR}/scripts/check-purchases.mjs .` and
   `node ${CLAUDE_SKILL_DIR}/scripts/check-purchase-behaviour.mjs .`; fix every `FAIL` line (each rule,
   contract key and expected SKIP line is in [references/checks.md](references/checks.md)) and rerun
   until both print `RESULT: PASS`. Run state-management-zustand's and naming-conventions' checkers too
   (list `storekit` in `contracts/naming.json` `extraE2eFolders` and in e2e-maestro's
   `contracts/e2e.json` `manualFolders`, without `startsOutside`).
6. Before a release and after an expo-iap upgrade, read
   [references/testing-tiers.md](references/testing-tiers.md): copy `templates/scripts/storekit/` and
   `templates/.maestro/storekit/`, with `templates/scripts/e2e/` when the repo lacks it (they also need
   e2e-maestro's debug sub-flow). Add `premium=0|1` and `open=paywall` to the debug-link registry
   with its test-only Premium handler, as [examples/debug-premium-link.ts](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.
7. For the store consoles read [references/store-consoles.md](references/store-consoles.md): ask the
   developer for the human steps, copy `templates/scripts/asc/` and run
   `node 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-iap` is 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-failed` and finishes nothing; 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 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_ID` and 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`, `*.xctest` or `get-task-allow`, and the Tier 3 runs passed.
- [ ] `node ${CLAUDE_SKILL_DIR}/scripts/check-purchases.mjs .` and `node ${CLAUDE_SKILL_DIR}/scripts/check-purchase-behaviour.mjs .` both print `RESULT: 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 prints `NOT 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`.
- **`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 check; use `'expo-iap'`.
- **Typing "$2.99" into a catalog, a component or a test of the screen.** Use `{priceText}` and `storePriceText`.
- **Formatting `product.price` with `style: 'currency'`.** It can disagree with the purchase sheet; show `displayPrice`.
- **Reading `getAvailablePurchases()` with the default active-only list.** Refunds disappear from it; pass `onlyIncludeActiveItemsIOS: false`.
- **A publish-only Premium store fed by a service that writes the row itself.** Two writers own one fact; the store's `grantPremium` writes, 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](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](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](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](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](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](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](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](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 reads `selectIsPremium`.
- `state-management-zustand` - the store pattern behind `grantPremium` and 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`, `attemptAsync` and 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 the `extraE2eFolders` entry.
- `debug-menu-and-test-hooks` - the debug deep link the flows use; the app adds `premium` and `open=paywall` to it (the screen parameter is `open`; its contract refuses `screen`).
- `value-objects-money-and-units` - Money from the store's double, and `formatMoney` when digits follow an in-app setting.
- `e2e-maestro` - Maestro, the debug sub-flow and the device binding.
- `ios-simulator-build` - simulators and `scripts/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.

Files in this skill

  • SKILL.md23.1 KB
  • assets/purchase-facts.json2.3 KB
  • assets/shared.json2.3 KB
  • assets/template-placeholders.json75 B
  • examples/create-device-services.ts1.8 KB
  • examples/debug-premium-link.ts3.7 KB
  • examples/purchase-root.tsx3.7 KB
  • references/checks.md9.3 KB
  • references/paywall-states.md18.4 KB
  • references/store-and-adapter.md13.5 KB
  • references/store-consoles.md7.8 KB
  • references/testing-tiers.md18.4 KB
  • scripts/check-purchase-behaviour.mjs44.4 KB
  • scripts/check-purchases.mjs28.7 KB
  • scripts/lib/app-modules.mjs8.7 KB
  • scripts/lib/module-stubs.mjs951 B
  • scripts/lib/purchase-repo.mjs7.1 KB
  • scripts/lib/source-scan.mjs35.1 KB
  • scripts/lib/stubs/expo-iap.mjs3.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.