iOS billing: Monthly/Yearly plans, refund request, analytics, unavailable-plans retry, no web purchase links

Merged
LA
23d ago
+413-57

Summary

App-side work for selling cmux Go, Pro and Max in the App Store app (com.cmux.app, ASC 6783338052).

  • Analytics (BillingModel, BillingAnalyticsEvent):
    • ios_products_loaded {product_ids (comma-separated), storefront, count, billing_period (the loaded periods, comma-separated)}. Sent once for each distinct outcome per account.
    • ios_products_failed {reason, storefront}. reason is a BillingFailure.analyticsReason, or no_server_products.
    • ios_purchase_succeeded {product_id, plan_id, billing_period, price, currency, environment (Production/Sandbox/Xcode), delivery (accepted|deferred)}. An approved Ask to Buy purchase also sends it.
    • ios_purchase_started also carries billing_period.
    • ios_refund_request_opened {product_id} and ios_refund_request_result {product_id, result: success|userCancelled|error}. Both names are added to web/services/analytics/iosEventPolicy.ts, which is the only web/ change.
  • Monthly / Yearly:
    • Each product carries billingPeriod, taken from the server field (#20587) or else the product-id suffix. The client sends x-cmux-billing-periods: monthly,yearly.
    • The plans screen has a segmented Monthly/Yearly picker. Yearly is the default, as on cmux.com/pricing. The picker shows only when a plan is sold in both periods.
    • The screen shows one offer per plan. Go is monthly only. Max is monthly only until Max Yearly is priced.
    • Yearly offers also show the per-month equivalent ("$58.33 / month, billed yearly").
    • The current App Store product comes from StoreKit current entitlements, so Pro Monthly and Pro Yearly are told apart and a user can switch between them.
    • The renewal disclosure names monthly or yearly renewal in 9 localizations.
    • ios/Config/cmux-dev.storekit gains Pro Yearly ($699.99, P1Y, same group level as Pro Monthly).
  • Request a Refund: shown when the plan is billed through the App Store. It opens StoreKit's .refundRequestSheet for the latest transaction of the active subscription. "Manage Subscription" uses StoreKit's .manageSubscriptionsSheet.
  • Bug: if the account was eligible but no products loaded, the spinner never stopped. Now the screen shows "Plans aren't available in the app right now." with Try Again, and the spinner returns while the retry runs.
  • Copy (3.1.1 / 3.1.3(b)): the message for web subscribers now reads "You subscribe on the web."
  • No external purchase link: without a billing model, the New Machine sheet no longer opens cmux.com/pricing.
  • Purchases still carry appAccountToken and post the JWS to the server before finish().

Verification

  • cmux-ci run ... run-swift-testing-suites.sh --arg Packages/iOS/CmuxMobileBilling passes at each head (latest cfee6eb2). The existing exact-name assertions are updated. No new unit tests.
  • Fleet cmux-ci build ios (device archive and simulator build) passes at ba640f25, 8352e856 and 40efd056. The build for cfee6eb2 is in the comments.
  • A fleet simulator run (verify-remote) of the plans screen with no products loading shows the new unavailable state with Try Again.