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
- App: Raxx by MooseQuest
- Bundle ID:
app.raxx.ios(MooseQuest LLC org team CM62W3T483) - ASC App ID:
6778650155
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):
- ASC → Apps → Raxx → Subscriptions → Raxx Pro Subscriptions → Raxx Pro+ Monthly
- Under Subscription Prices, click + Add Introductory Offer
- Set: - Type: Pay As You Go - Price: $29.00/month (USD) - Number of periods: 6 - Duration per period: 1 month
- 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
- Xcode menu: Product → Scheme → Edit Scheme
- Select the Run action (left sidebar)
- Options tab
- 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 | 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
- Go to
appstoreconnect.apple.com— sign in withkris@moosequest.net+ 2FA. - Navigate to Users and Access → Sandbox → Testers.
- Click + (Add Sandbox Tester).
- Fill in the form:
| Field | Value |
|---|---|
| First name | IAP |
| Last name | TestFresh |
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 |
- Click Save. Apple sends a verification email to the tester address.
- Check
ops@raxx.app(Google Workspace inbox) for the verification link. Complete it. The account is not usable until the email is verified. - Repeat for
iap-test-proplus@raxx.appusing theIAP_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
- 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).
- Disable the StoreKit configuration in the scheme before building: Edit Scheme → Run → Options → StoreKit Configuration → None
- Build and install: Cmd+R with the device selected in Xcode.
Purchase flow
- Open Raxx. Sign in with the operator passkey.
- Navigate to the billing screen (Dashboard → tap your plan tier → Billing, or similar nav path).
- Tap Subscribe on any tier card.
- 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). - The sandbox sheet shows
[Environment: Sandbox]in the header area. - 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:
Product.products(for:)resolves against the local.storekitfile — no Apple network callStoreKitManagerTests.testStorekitFileProductIDsMatchCodeConstantscatches ID drift between Swift constants and the.storekitfile- Actual
product.purchase()calls are NOT triggered in unit tests (require user interaction)
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
- Sandbox URL:
https://api-staging.raxx.app/api/subscriptions/apple/notifications - Production URL: leave blank until prod E2E is separately authorized
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
- ADR-0007:
docs/architecture/adr/0007-ios-subscription-billing-iap.md - StoreKit config file:
ios/Raxx/Resources/RaxxProducts.storekit - Xcode scheme:
ios/Raxx.xcodeproj/xcshareddata/xcschemes/Raxx.xcscheme - StoreKit 2 manager:
ios/Raxx/Features/Billing/StoreKitManager.swift - Billing screen:
ios/Raxx/Features/Billing/BillingView.swift - Billing test tooling (Stripe + Apple overview):
docs/ops/runbooks/billing-test-tooling.md - iOS README (install + Phase D IAP checklist):
ios/README.md - On-device E2E runbook (#3688):
docs/ops/runbooks/ios-iap-e2e-device-runbook.md - Apple — StoreKit testing in Xcode:
https://developer.apple.com/documentation/storekit/testing_in_xcode_and_the_sandbox_environment - Apple — App Store Server Notifications v2:
https://developer.apple.com/documentation/appstoreservernotifications - Apple — Sandbox tester management:
https://developer.apple.com/documentation/storekit/testing_in_the_sandbox_environment