Raxx · internal docs

internal · gated

iOS IAP sandbox testing

System: Raxx iOS app — StoreKit 2 subscription billing Owner: ios-agent / operator Companion: docs/ops/runbooks/billing-test-tooling.md §7 (Apple IAP overview) ADR: ADR-0007 — docs/architecture/adr/0007-ios-subscription-billing-iap.md Last reviewed: 2026-10-31 (updated for the iOS paywall, #4832: annual Pro, 7-day Pro trial, intro-offer eligibility; earlier: 2-product launch model; Founders = Pro+ intro offer; prices locked; sandbox tester creds staged in vault)


Current IAP / StoreKit implementation state

Component Status Notes
Product ID constants (RaxxProductID in ProductIDs.swift; StoreKitManager.ProductID is an alias) REAL — matches .storekit app.raxx.ios.pro.monthly, app.raxx.ios.pro.annual, app.raxx.ios.proplus.monthly
Product.products() fetch REAL Loads from local StoreKit config in dev/test; from App Store in production
product.purchase() flow REAL StoreKit 2 native; handles .success, .userCancelled, .pending
Transaction.updates listener REAL Wired in StoreKitManager.init; cancelled in deinit — no receipts lost
Transaction.currentEntitlements REAL StoreKitManager.currentEntitlements() returns Set<String> of active product IDs
Restore purchases UI REAL Billing screen (refreshEntitlements()) and the paywall (AppStore.sync() then the verified current entitlements; found, nothing to restore and failed states)
Paywall (PaywallScreen, #4832) REAL, behind onboarding_paywall_v1 (server switch, default OFF) Pro default, Free at equal weight, annual Pro, Apple-provided price and period, trial wording only when isEligibleForIntroOffer is true. The flag stays OFF until #4836 is closed
Transaction.finish() after purchase REAL Called after both purchase and updates listener verify
StoreKit Configuration file REAL ios/Raxx/Resources/RaxxProducts.storekit — 3 products: pro.monthly and pro.annual (each with the 7-day free intro offer, configured as 1 week as in App Store Connect), proplus.monthly (Founders pay-as-you-go intro, no trial)
Scheme wiring (local testing) REAL Raxx.xcscheme has storeKitConfigurationFileReference set for both TestAction and LaunchAction
App Store Connect product records PROVISIONED (MISSING_METADATA) 2 launch products in ASC; prices need to be set; review screenshot needed for READY_TO_SUBMIT
Sandbox tester accounts CREDENTIALS STAGED — ASC DASHBOARD STEP REQUIRED Credentials in vault at /Raxx/iOS/Sandbox/; operator must create accounts in ASC UI (see §2)
Server-side transaction verification NOT IMPLEMENTED See §6 — gaps tracked in sub-cards #3685/#3686/#3687

The iOS app can initiate, complete, and observe purchases end-to-end in the Simulator (StoreKit local config) and in the Apple sandbox (sandbox tester on device). Server-side JWS verification and entitlement persistence (sub-cards #3685–#3687) are the remaining gap for full billing SOP.


0. App Store Connect product record status

App record

Active launch products (as of 2026-07-13)

Product ID ASC ID Type Sub group Tier Period Price (locked) ASC state
app.raxx.ios.pro.monthly 6781305735 Auto-renewable Raxx Pro Subscriptions (22164259) Pro 1 month $39.00/mo MISSING_METADATA
app.raxx.ios.proplus.monthly 6781305906 Auto-renewable Raxx Pro Subscriptions (22164259) Pro+ / Founders 1 month $79.00/mo (standard); $29.00/mo × 6 periods (Founders intro) MISSING_METADATA

Retired products (no longer in code): - app.raxx.ios.founders.6mo (ASC ID 6781305235) — non-renewing; retired per issue #3889. The Founders offer is now modelled as the Pro+ monthly subscription with a pay-as-you-go introductory offer. The ASC non-renewing record must be deactivated in the ASC dashboard — operator action required. - app.raxx.ios.pro.annual (ASC ID 6781305904) — excluded from launch scope (monthly-only, 2026-06-28). Draft record exists in ASC; do not activate until annual pricing is decided.

Superseded 2026-10-31 UTC: annual pricing is decided. Annual Pro ($390 per year) is offered at launch; see [ADR-0152](https://internal-docs.raxx.app/architecture/adr/0152-funnel-pricing-and-trial-decisions.html) sections 2.2 and 3. The "excluded" and "do not activate" wording above is stale; activation and the 7-day intro offer are tracked by #4836.

Prices — locked, pending ASC dashboard entry

Prices are locked per project_pricing_tiers_locked: - Pro monthly: $39.00/mo - Pro+ monthly (standard): $79.00/mo - Founders introductory offer on Pro+: $29.00/mo × 6 periods (pay-as-you-go), then auto-renews at $79.00/mo

These values are confirmed in RaxxProducts.storekit (the Simulator reference file). The ASC records currently have no price set — an operator dashboard step is needed: ASC → Apps → Raxx → Subscriptions → Raxx Pro Subscriptions → [product] → Pricing → Set Base Price

Founders introductory offer — ASC configuration required

The Founders offer is a StoreKit introductory offer on app.raxx.ios.proplus.monthly. It must be configured in ASC dashboard (not yet done):

  1. ASC → Apps → Raxx → Subscriptions → Raxx Pro Subscriptions → Raxx Pro+ Monthly
  2. Under Subscription Prices, click + Add Introductory Offer
  3. Set: - Type: Pay As You Go - Price: $29.00/month (USD) - Number of periods: 6 - Duration per period: 1 month
  4. Save.

When the offer is live in ASC, the paywall and BillingView show the Founders offer as a note on Pro+ to accounts that are eligible for an introductory offer in the subscription group (Product.SubscriptionInfo.isEligibleForIntroOffer(for:), asked of StoreKit every time, never inferred from local state). The offer copy is built from the Apple-provided price, period and period count by PaywallCopy.introLine, so nothing about the offer is typed in code.

State: MISSING_METADATA — what it means

MISSING_METADATA does NOT block sandbox testing. Products in this state are fully functional in the Apple sandbox environment. What MISSING_METADATA blocks is submission for App Review.

To move each product to READY_TO_SUBMIT: 1. Set a base price (see above). 2. Upload a review screenshot: ASC → In-App Purchases → [product] → Review Information → Screenshot. The screenshot should show the billing/paywall screen in the app. A Simulator screenshot is acceptable.


1. StoreKit Configuration file (local Simulator testing)

What it is

ios/Raxx/Resources/RaxxProducts.storekit is a local product catalogue that Xcode's StoreKit testing framework reads instead of contacting the live App Store. When active, Product.products() returns the three Raxx products and product.purchase() completes with a locally-signed VerificationResult — no Apple account, no network, no charges.

How to verify it is active

  1. Xcode menu: Product → Scheme → Edit Scheme
  2. Select the Run action (left sidebar)
  3. Options tab
  4. Confirm StoreKit Configuration shows RaxxProducts.storekit

The scheme already wires this for both the Run and Test actions. No manual step needed.

To test against the real Apple sandbox (on device)

Set StoreKit Configuration to None for the Run action. With None, the app contacts the real App Store sandbox and Apple's payment sheet appears. This requires a sandbox tester account (§2 below) and a physical device.

Products in RaxxProducts.storekit

Product ID Type Price (Simulator) Introductory offer
app.raxx.ios.pro.monthly Auto-renewable, 1-month $39.00 Free, 1 week (shown as 7 days)
app.raxx.ios.pro.annual Auto-renewable, 1-year $390.00 Free, 1 week (shown as 7 days)
app.raxx.ios.proplus.monthly Auto-renewable, 1-month $79.00 Pay As You Go: $29.00 × 6 periods (Founders); no trial

App Store Connect has no "7 days" duration for a free trial (3 days, 1 week, 2 weeks, 1 month, ...), so the 7-day trial is a 1 week offer in both the local file and App Store Connect. The paywall writes a week-based period in days (weeks x 7) so its copy matches the web paywall and ADR-0152.

Note: The prices in the .storekit file are simulation values only. The Simulator does not charge anything. The ASC base prices ($39/$79) are the authoritative prices that appear on a real device.

Sandbox renewal cadence (Simulator / StoreKit local config)

Subscription period Simulator duration
1 month ~2 minutes
1 year ~3 minutes

Override via Debug → StoreKit → Manage Transactions in Xcode.


1a. Paywall test plan (#4832)

Run against the local StoreKit configuration with SKTestSession (RaxxTests/PaywallStoreKitTests.swift) and the fixture store in UI tests (RaxxUITests/PaywallUITests.swift, -UITestMockNetwork -UITestMockState paywall...). The live annual product and intro offers are verified against the real Apple sandbox in #4836; keep onboarding_paywall_v1 OFF until that card is closed.

# Case Automated by Expect
T1 Eligible account testEligibleAccountSeesTheTrialOnBothProPlans Trial wording on both Pro rows, "Start 7 days free", price and period strings from StoreKit
T2 Ineligible account testIneligibleAccountSeesPlainPricingEverywhere (subscribe, expire, reopen) No trial wording anywhere; plain price lines
T3 Offer not available testNoIntroOfferConfiguredReadsAsPlainPricing (offer removed from a copy of the config) Same as T2; no crash, no empty "free" text
T4 Annual product testAnnualPlanDerivesPerMonthAndMonthsFree, testMissingAnnualIsHidden Row, $32.50 per month equivalent, "2 months free" only for a whole number; a missing annual row is hidden
T5 Pro+ and Founders testFoundersNoteShowsWhileEligibleAndNeverOnATrial, testTakingTheTrialEndsFoundersEligibilityForTheGroup Founders note while eligible, no trial on Pro+, hidden once the group's introductory offer is used
T6 Restore testRestoreFindsAnActiveSubscription, testRestoreWithNothingToRestore Found, nothing, failed (PaywallModelTests)
T7 Ask to Buy testAskToBuyIsPending Pending state, Free is the way out
T8 Purchases restricted PaywallModelTests.testPurchasesRestrictedNeverFetchesOrOffersAPlan, PaywallUITests AppStore.canMakePayments cannot be switched in a test session, so a fake store reports false; plans and purchase button removed, Free and Restore stay
T9 Fold or rotate mid-paywall PaywallUITests.testPaywallKeepsItsSelectionAcrossRotation, PaywallModelTests.testViewedFiresOnceWhenPlansFirstLeaveLoading Selection kept, paywall_viewed once. Fold and unfold on the Duo inner display are Device Hub poses (operator)
T10 Purchase binds to the account PaywallModelTests.testPurchaseIsStampedWithTheServerVendedToken, testNoTokenMeansNoPurchaseSheetAndNothingCharged, test_apple_iap_account_token_4832.py (backend) The token stamped on the purchase is the one the server vended for the signed-in account; with no token (route off, no session, offline) the Apple sheet never opens
T11 Link not landed, every kind PaywallModelTests.testAnEligibleTrialPurchaseThatTheServerHasNotLinkedIsReportedUnlinked, PaywallModelTests.testRestoredSubscriptionTheServerDoesNotHoldIsReportedUnlinkedNotAssumedLinked, PaywallUITests.testATrialTheServerHasNotLinkedSaysSoAndTryAgainLinksIt, PaywallUITests.testARestoredSubscriptionTheServerDoesNotHoldSaysSoAndKeepsTheUserMoving A trial, a plain subscription and a restore all show "Linking it to your Raxx account did not finish." with Try again when the server has not linked them
T12 Try again is seen to do something PaywallModelTests.testTheLinkShowsCheckingWhileTryAgainAsksTheServerAndNotBefore, PaywallModelTests.testATryAgainThatDoesNotLinkIsRecordedAndTheNextOneClearsItWhileItRuns, PaywallModelTests.testATryAgainThatLinksLeavesNoFailureBehind, PaywallCopyTests.testTheSpokenRetryResultIsTheTextDrawnOnTheNotice, PaywallUITests.testARestoredSubscriptionTheServerDoesNotHoldSaysSoAndKeepsTheUserMoving While the server is asked the link reads "Checking" with a spinner; if the link still has not landed, "Still not linked to your Raxx account." is drawn on the notice (the same words VoiceOver speaks); a retry that links removes the notice

Flag-ON prerequisites (all of them): onboarding_paywall_v1 ON (and beta_phase2_access OFF: the closed beta keeps the paywall off, as on the web), the live annual product and 7-day intro offers (#4836), PaywallStoreKitTests run once from Xcode (the cases skip under xcodebuild), AND apple_iap ON in the environment the build talks to, which serves both the account-token route (POST /api/subscriptions/apple/account-token, so a purchase can be bound to the account) and the entitlement check. With apple_iap OFF those routes return 404, the app never opens the Apple sheet (a payment the server cannot link would leave a paid account on Free), and the purchase shows "The purchase did not complete." Flags are environment-wide: there is no per-account switch.

To run the plan by hand in the Simulator, set the Run scheme's StoreKit configuration to RaxxProducts.storekit (already the default) and turn onboarding_paywall_v1 ON in that environment through the server switch (it applies to every account there); use Debug > StoreKit > Manage Transactions to expire a subscription (T2) and the scheme's Ask to Buy option (T7).


2. App Store Connect sandbox tester setup

The ASC REST API does not support CREATE for sandbox testers. Confirmed 2026-06-17: "The resource 'sandboxTesters' does not allow 'CREATE'. Allowed operations are: GET_COLLECTION, UPDATE". Sandbox tester creation requires the ASC dashboard with the operator's Apple ID + 2FA.

Credentials are staged in Infisical at /Raxx/iOS/Sandbox/. Retrieve them via vault before starting. Do NOT paste them here.

Tester accounts to create

Vault key prefix Email Purpose
IAP_TESTER_FRESH_* iap-test-fresh@raxx.app Happy path: first-time Pro subscriber, clean slate
IAP_TESTER_PROPLUS_* iap-test-proplus@raxx.app Pro+ Founders intro offer test

For the #3688 E2E, the minimum is one: iap-test-fresh@raxx.app.

Creation steps

  1. Go to appstoreconnect.apple.com — sign in with kris@moosequest.net + 2FA.
  2. Navigate to Users and Access → Sandbox → Testers.
  3. Click + (Add Sandbox Tester).
  4. Fill in the form:
Field Value
First name IAP
Last name TestFresh
Email from vault: IAP_TESTER_FRESH_EMAIL
Password from vault: IAP_TESTER_FRESH_PASSWORD
Secret question + answer choose any; store answer in vault at /Raxx/iOS/Sandbox/IAP_TESTER_FRESH_SECRET_ANSWER
Date of birth any past date (e.g. 1990-01-15)
Store region United States
  1. Click Save. Apple sends a verification email to the tester address.
  2. Check ops@raxx.app (Google Workspace inbox) for the verification link. Complete it. The account is not usable until the email is verified.
  3. Repeat for iap-test-proplus@raxx.app using the IAP_TESTER_PROPLUS_* vault keys.

Reset purchase history (between test runs)

ASC → Users and Access → Sandbox → Testers → [tester] → Manage → Clear Purchase History

This resets sandbox renewal eligibility. Use it between test sessions to start fresh.


3. Testing a sandbox purchase on a real iPhone

Apple IAP does NOT function in the Simulator. The Simulator uses the local StoreKit config file (see §1). A physical iPhone is required to exercise the real Apple sandbox payment sheet.

Device setup

  1. On the iPhone: system-level Apple ID can remain signed in — Apple's sandbox tester sign-in happens inside the app's payment sheet, not at the system level. Just ensure the system-level Apple ID is NOT a sandbox tester email (those accounts are for in-app prompts only).
  2. Disable the StoreKit configuration in the scheme before building: Edit Scheme → Run → Options → StoreKit Configuration → None
  3. Build and install: Cmd+R with the device selected in Xcode.

Purchase flow

  1. Open Raxx. Sign in with the operator passkey.
  2. Navigate to the billing screen (Dashboard → tap your plan tier → Billing, or similar nav path).
  3. Tap Subscribe on any tier card.
  4. Apple's payment sheet appears. When it prompts for Apple ID, enter the sandbox tester credentials from vault (IAP_TESTER_FRESH_EMAIL / IAP_TESTER_FRESH_PASSWORD).
  5. The sandbox sheet shows [Environment: Sandbox] in the header area.
  6. Confirm the purchase. No real charge occurs.

Verifying the purchase

// In BillingViewModel or wherever you gate entitlement:
let entitled = await storeKitManager.currentEntitlements()
assert(entitled.contains("app.raxx.ios.pro.monthly"))

In Xcode with the device connected: Debug → StoreKit → Manage Transactions shows all sandbox transactions even for device-based purchases.

Sandbox renewal cadence on device

Real duration Sandbox duration on device
1 month ~5 minutes
6 months ~30 minutes
1 year ~1 hour

Apple caps renewals at 12 per subscription. Clear purchase history in ASC to reset.


4. Managing and resetting sandbox state

Cancelling a sandbox subscription (device)

Settings → [sandbox tester name] → Subscriptions → Raxx [tier] → Cancel Subscription

This appears in iOS Settings only after a sandbox purchase with that account.

Xcode Transaction Manager (Simulator or device-connected)

Debug → StoreKit → Manage Transactions

From here you can: - View all purchases in the current session - Refund a transaction (simulates an Apple refund notification) - Expire a subscription (accelerates the ~5 min sandbox period) - Reset all purchase history for the Simulator


5. CI / automated testing

The GitHub Actions ios-ci.yml workflow runs xcodebuild test with the Raxx scheme. The scheme has storeKitConfigurationFileReference pointing to RaxxProducts.storekit. In CI:

The .storekit file is plain JSON tracked in git. No secrets needed for CI runs.


6. Server-side transaction verification (gap — sub-cards #3685–#3687)

The client-side purchase flow is complete. What's missing is the backend pipeline:

POST /api/subscriptions/apple/notifications

This endpoint must: 1. Parse the signedPayload JWS from Apple's server-to-server notification. 2. Verify the JWS signature against Apple Root CA G3. 3. Decode originalTransactionId, notificationType, productId, expiresDate. 4. Handle SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, REFUND, REVOKE. 5. Return 200 on success (Apple retries on non-200).

Sub-cards: - #3685 — JWS verify - #3686 — persist (apple_iap_subscriptions + apple_iap_notification_log) - #3687 — entitlement + reconciliation

Until these are merged and FLAG_APPLE_IAP=1 is set in staging, only the client-side half of the iOS billing SOP is testable. The on-device runbook at docs/ops/runbooks/ios-iap-e2e-device-runbook.md covers the client-side taps only and is self-contained for the #3688 device verification.

Notification URL configuration

Once sub-cards ship, set in ASC: Apps → Raxx → App Information → App Store Server Notifications


7. Pre-flight checklist for a sandbox purchase test

[ ] StoreKit Configuration = NONE in scheme (Run → Options → StoreKit Conf = None)
    (Enable .storekit ONLY for Simulator / unit test work; NONE for real sandbox on device)

[ ] Device is running the Raxx app built from Xcode (Cmd+R, target = your device)

[ ] Sandbox tester account created and email-verified in ASC (§2)
    Credentials at /Raxx/iOS/Sandbox/ in vault

[ ] Device system-level Apple ID is not the sandbox tester email

[ ] Xcode console open (Window → Devices and Simulators → your device → Open Console)
    to watch StoreKit log output during the purchase

[ ] For server-side verification (optional for #3688 device E2E):
    Sub-cards #3685/#3686/#3687 merged, FLAG_APPLE_IAP=1 in staging, WAF skip rule deployed

References