Skip to content
Agentmart
Submit a tool

in-app-purchases

Sells a Premium unlock with expo-iap behind a PurchasePort - saved entitlement, paywall states, restore, refunds, store testing.

Skill by zakariaf

IndexedMIT★ 11
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/zakariaf-in-app-purchases

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

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).

Use cases: testing, marketing

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md and code. Checked Oct 26, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found
Runs as
Node.js package

Network hosts it talks to 2

  • api.appstoreconnect.apple.com · found in code
  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/in-app-purchases
  • Your home directory (~)

What's inside

Skill 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).
License
MIT
Instructions
226 lines in SKILL.md · view on GitHub

Before installing

  • Ships executable scripts

Agent Skills spec issues

  • files: referenced but missing: scripts/storekit/Premium.storekit, scripts/e2e/maestro-args.ts

Files (124)

  • SKILL.md
  • purchase-facts.json · assets/
  • shared.json · assets/
  • template-placeholders.json · assets/
  • create-device-services.ts · examples/
  • debug-premium-link.ts · examples/
  • purchase-root.tsx · examples/
  • checks.md · references/
  • paywall-states.md · references/
  • store-and-adapter.md · references/
  • store-consoles.md · references/
  • testing-tiers.md · references/
  • check-lib.mjs · scripts/
  • check-purchase-behaviour.mjs · scripts/
  • check-purchases.mjs · scripts/
  • app-modules.mjs · scripts/lib/
  • module-stubs.mjs · scripts/lib/
  • purchase-repo.mjs · scripts/lib/
  • source-scan.mjs · scripts/lib/
  • expo-iap.mjs · scripts/lib/stubs/
  • selftest.mjs · scripts/
  • 01-buy.yaml · templates/.maestro/storekit/
  • 02-refund-relaunch.yaml · templates/.maestro/storekit/
  • 03-ask-to-buy.yaml · templates/.maestro/storekit/
  • 04-approval-relaunch.yaml · templates/.maestro/storekit/
  • 05-restore.yaml · templates/.maestro/storekit/
  • 06-failure.yaml · templates/.maestro/storekit/
  • 07-store-unavailable.yaml · templates/.maestro/storekit/
  • expo-iap.ts · templates/__mocks__/
  • in-app-purchases.json · templates/contracts/
  • in-app-purchases.schema.json · templates/contracts/
  • asc-client.ts · templates/scripts/asc/
  • asc-credentials.ts · templates/scripts/asc/
  • asc-jwt.test.ts · templates/scripts/asc/
  • asc-jwt.ts · templates/scripts/asc/
  • create-premium-iap.ts · templates/scripts/asc/
  • iap-contract.ts · templates/scripts/asc/
  • premium-iap-payloads.test.ts · templates/scripts/asc/
  • premium-iap-payloads.ts · templates/scripts/asc/
  • system-clock.test.ts · templates/scripts/clock/
  • system-clock.ts · templates/scripts/clock/
  • maestro-args.test.ts · templates/scripts/e2e/
  • maestro-args.ts · templates/scripts/e2e/
  • ArmTests.swift · templates/scripts/storekit/
  • Premium.storekit · templates/scripts/storekit/
  • add-harness.rb · templates/scripts/storekit/
  • busy-note.test.ts · templates/scripts/storekit/
  • busy-note.ts · templates/scripts/storekit/
  • storekit-harness.entitlements · templates/scripts/storekit/
  • storekit-harness.ts · templates/scripts/storekit/
  • purchase-products.ts · templates/src/config/
  • result.test.ts · templates/src/core/
  • result.ts · templates/src/core/
  • entitlement-repository.ts · templates/src/data/entitlements/
  • fake-entitlement-repository.ts · templates/src/data/entitlements/
  • sqlite-entitlement-repository.ts · templates/src/data/entitlements/
  • sql-driver.ts · templates/src/data/
  • connectivity-port.ts · templates/src/services/connectivity/
  • expo-network-connectivity-adapter.test.ts · templates/src/services/connectivity/
  • expo-network-connectivity-adapter.ts · templates/src/services/connectivity/
  • …and 64 more

SKILL.md (first lines)

# 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

From zakariaf/ReactNative-Skills, MIT.

More from zakariaf/ReactNative-Skills

Signals

Stars
11
Forks
2
Open issues
0
Last commit (this folder)
Oct 17, 2026 (12 days ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from zakariaf

Cite this page

in-app-purchases skill. Agent Mart. https://agentmart.dev/skills/zakariaf-in-app-purchases (last updated 2026-10-24)