Skip to main content
ZSMigrationManager is the state machine behind ZSMigrateTipView. If the built-in view doesn’t fit your design, use the manager directly to build a fully custom switch and save experience while reusing all the eligibility logic, checkout orchestration, and state tracking.

Quick Start

struct SwitchAndSaveBanner: View {
    // Pass stripeCustomerId to attach checkouts to an existing Stripe customer
    @StateObject private var manager = ZSMigrationManager(userId: "user_42")
    // Or: ZSMigrationManager(userId: "user_42", stripeCustomerId: "cus_abc123")

    var body: some View {
        switch manager.state {
        case .eligible:
            if let offer = manager.offerData {
                VStack {
                    Text(offer.prompt.title)
                    Text(offer.prompt.message)
                    Button(offer.prompt.ctaText) {
                        Task { await manager.startCheckout() }
                    }
                }
            }

        case .accepted:
            Button("Cancel Apple Billing") {
                Task { await manager.showAppleSubscriptionManagement() }
            }

        case .completed:
            Text("You're saving \(manager.offerData?.prompt.discountPercent ?? 15)%!")

        default:
            EmptyView()
        }
    }
}
The manager is an ObservableObject — SwiftUI re-renders automatically when the state changes.

State Machine

The switch and save flow is a linear state machine. Each state represents a step in the user journey.
loading → ineligible
loading → dismissed
loading → eligible → presented → accepted → completed
                                                ↘ dismissed
                  ↘ dismissed
StateMeaningofferData
loadingWaiting for ZeroSettle.bootstrap() to finish.nil
ineligibleUser doesn’t qualify (no StoreKit subscription, already switched, no campaign configured).nil
eligibleUser qualifies. Offer data is available — show your UI.Available
presentedOffer is on screen or checkout is in progress.Available
acceptedWeb checkout succeeded. Prompt the user to cancel Apple billing.Available
completedUser opened Apple subscription management. Switch and save flow is done.Available
dismissedUser closed the offer. Persisted in UserDefaults across launches.nil
The manager locks mid-flow states (presented, accepted, completed) so that background re-evaluations (e.g., entitlement refreshes) don’t disrupt an active checkout.

Published Properties

PropertyTypeDescription
stateMigrationOffer.StateCurrent state of the switch and save flow.
offerDataMigrationOffer.OfferData?Offer details (prompt text, discount, free trial days). Available from .eligible onward.
isLoadingBooltrue while startCheckout() is creating a payment intent.
checkoutErrorError?The last error from startCheckout(), if any.
userIdStringThe user identifier this manager was created with.
stripeCustomerIdString?Optional Stripe Customer ID passed at init. When set, checkouts are attached to this existing customer.

Offer Data

When state is .eligible or later, offerData contains everything you need to render the offer.

MigrationOffer.OfferData

FieldTypeDescription
promptMigrationPromptTitle, message, CTA text, discount, and product ID from your dashboard campaign.
freeTrialDaysIntDays remaining on the user’s current StoreKit subscription (bridged as a free trial on web).
activeStoreKitProductIdStringThe product ID of the user’s active StoreKit subscription.

MigrationPrompt

FieldTypeDescription
productIdStringThe web product ID to offer for switch and save.
discountPercentIntThe discount percentage (e.g., 15 for 15% off).
titleStringHeading text for the offer.
messageStringBody text explaining the offer.
ctaTextStringButton label (e.g., “Save 15% Forever”).
All of these are configured in your dashboard switch and save campaign.

Methods

startCheckout() async -> URL?

Creates a payment intent and returns a checkout URL. Transitions state from .eligible to .presented.
if let url = await manager.startCheckout() {
    // Open url in a webview, SFSafariViewController, or Safari
}
Returns nil on failure — check manager.checkoutError for details.

present()

Manually transition from .eligible to .presented without creating a checkout URL. Use this when you handle checkout via ZeroSettle.shared.purchase() (Safari / SFSafariViewController flow) instead of an inline webview.
manager.present()

markCheckoutSucceeded()

Call this after a successful web checkout to transition from .presented to .accepted. Fires conversion tracking automatically.
manager.markCheckoutSucceeded()

showAppleSubscriptionManagement() async

Opens the system subscription management sheet (StoreKit AppStore.showManageSubscriptions). Transitions from .accepted to .completed when the sheet dismisses.
await manager.showAppleSubscriptionManagement()

dismiss()

Dismisses the offer from any state. Sets state to .dismissed and persists the dismissal in UserDefaults so it won’t reappear.
manager.dismiss()

Static Methods

resetDismissedState()

Clears the persisted dismissal, allowing the offer to reappear. Useful for debugging.
ZSMigrationManager.resetDismissedState()

Full Example

A complete custom switch and save card with loading, error, checkout, and completion states:
struct CustomSwitchAndSaveCard: View {
    @StateObject private var manager: ZSMigrationManager

    init(userId: String) {
        _manager = StateObject(wrappedValue: ZSMigrationManager(userId: userId))
    }

    var body: some View {
        Group {
            switch manager.state {
            case .loading, .ineligible, .dismissed:
                EmptyView()

            case .eligible, .presented:
                eligibleView

            case .accepted:
                acceptedView

            case .completed:
                completedView
            }
        }
        .animation(.easeInOut, value: manager.state)
    }

    private var eligibleView: some View {
        VStack(spacing: 12) {
            if let offer = manager.offerData {
                Text(offer.prompt.title).font(.headline)
                Text(offer.prompt.message).font(.subheadline)

                if let error = manager.checkoutError {
                    Text(error.localizedDescription)
                        .foregroundColor(.red)
                        .font(.caption)
                }

                Button {
                    Task { await manager.startCheckout() }
                } label: {
                    if manager.isLoading {
                        ProgressView()
                    } else {
                        Text(offer.prompt.ctaText)
                    }
                }
                .disabled(manager.isLoading)
            }
        }
        .padding()
    }

    private var acceptedView: some View {
        VStack(spacing: 12) {
            Text("Almost done!")
            Text("Cancel your Apple subscription to start saving.")
            Button("Cancel Apple Billing") {
                Task { await manager.showAppleSubscriptionManagement() }
            }
        }
        .padding()
    }

    private var completedView: some View {
        VStack(spacing: 8) {
            Text("You're all set!").font(.headline)
            Text("Saving \(manager.offerData?.prompt.discountPercent ?? 15)% forever.")
        }
        .padding()
    }
}

Relationship to ZSMigrateTipView

ZSMigrateTipView uses ZSMigrationManager internally. Choose based on your needs:
ZSMigrateTipViewZSMigrationManager
EffortDrop-in, zero UI codeYou build the UI
CustomizationBackground color, fonts, borderFull control over layout, animations, copy
CheckoutBuilt-in inline webviewYou choose: webview, Safari, SFSafariViewController
State trackingHandled internallyYou observe and react to state changes
PlatformSwiftUI, React Native, FlutterSwiftUI only

Next Steps