Skip to content

Route web and macOS cancellations through the mobile cancel flow (SCA-2171) - #25273

Open
Git-on-my-level wants to merge 14 commits into
mainfrom
agent/m1-studio-opencode-deepseek-v4/aad4bd09661f
Open

Git-on-my-level wants to merge 14 commits into
mainfrom
agent/m1-studio-opencode-deepseek-v4/aad4bd09661f

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Nov 14, 2026 •

Copy link
Copy Markdown
Member

What

Web and macOS cancellations now run the mobile cancel flow: reason → optional feedback → consequences with the existing 50%-off-3-months stay offer → confirm. The reason and details are posted to the existing DELETE /v1/payments/subscription; the offer uses the existing GET/POST /v1/payments/retention-offer. No new endpoint, event, price, plan, ceiling or payer state.

  • Web: PlansSheet and SettingsPage cancel buttons open the shared CancelSubscriptionFlow; the old plain ConfirmDialog remains as the flag-off fallback.
  • macOS: a new Cancel Subscription row under the current-plan card opens CancelSubscriptionFlowSheet; Manage still opens the Stripe portal unchanged.
  • Analytics: the five existing mobile cancel events are emitted with platform: web / platform: macos. Mobile is untouched (it keeps app_platform).
  • Kill switch: cancel_flow_parity (CANCEL_FLOW_PARITY_ENABLED), read from the Omi flag store on both clients. Missing/unlisted/failed reads keep today's plain confirm and Manage-only paths. The flag never gates the cancel entry point. Registered as a config_switch in config/feature-flags.yaml with fail: closed and no production flip here.

Checklist

  • web reason step
  • web save-offer step
  • macOS reason step
  • macOS save-offer step
  • platform property on events
  • before/after renders web + macOS
  • exact copy attached
  • tests for both flows
  • PR checks green

Exact copy (shipped mobile English, string for string)

Step 1: “Why are you canceling?” / “Can you tell us why you're leaving?” / Too expensive · Not using it enough · Missing features · Audio/transcription quality · Battery drain concerns · Found an alternative · Other / Continue.

Step 2: per-reason titles and subtitles from app_localizations_en.dart (e.g. too_expensive → “What price would work for you?” / “Your feedback helps us find the right balance.”), 300-character field, Continue / Skip for Now.

Step 3: “Just a moment, please” / “We highly recommend exploring your other options instead of canceling.” / “Stay and pay {price} a month for the next {months} months.” / “Your plan will remain active until {date}. After that, you'll be moved to the free version with limited features.” / “If you cancel:” + the six mobile consequence lines / “Stay for {percent}% off” or “Keep Subscription” / “Cancel Subscription” / success: “Your subscription is set to cancel at the end of the period.” or “Done. {percent}% off your next {months} months.”

Tests

  • cd web/app && bun run check — typecheck + 1080 vitest tests pass, including the new flow, kill-switch fallback, error-retry and offer paths.
  • cd desktop/macos/Desktop && swift test --filter 'CancelSheetLifecycleTests|CancelSubscriptionFlowTests' — 12 tests pass (1 render test skipped unless OMI_CANCEL_FLOW_RENDER_DIR is set). CancelSheetLifecycleTests pins the sheet-presentation lifetime: the cancel row disappears when a successful cancellation sets cancelAtPeriodEnd, and the done screen survives that refresh until the user dismisses it.
  • macOS renders regenerated from the fixed tree are byte-identical to the committed evidence packet (same SHA-256 for all seven images), so the approved packet below still shows exactly what ships.
  • Renders: .agent-artifacts/ui-evidence/sca-2171-cancel-flow-parity/ (web via hermetic Playwright, macOS via the shipped SwiftUI sheet).

UI evidence

Web before: plain confirm
Web after: reason step
Web after: offer step
macOS before: Manage only
macOS after: reason step
macOS after: offer step

Failure class (fixes)

Failure-Class: new

Failure-class transition narrative

This PR adds one class, FC-presenter-removed-with-its-conditional-row: a sheet presented by a row the flow itself hides is torn down with that row, so the flow's own result never reaches the user. The macOS cancel sheet hung off the Cancel Subscription row; a successful cancellation refreshes the subscription, sets cancelAtPeriodEnd, and removes that row, which dismissed the done screen mid-flow. The fix moves the presenter to the plan-usage section (a stable ancestor) and drives it from that section's state, and the guard artifact is desktop/macos/Desktop/Tests/CancelSheetLifecycleTests.swift, which pins the row's disappearance, the section-level presentation, and the done screen holding until explicit dismissal. No other failure-class definition is changed.

Product invariants affected

  • INV-AUTH-1 — the only APIClient.swift change adds an optional JSON body to the existing authenticated DELETE helper; auth policy, owner binding, refresh and session invalidation are untouched.

Notes

Line-Count-Exception: desktop/macos/Desktop/Sources/AnalyticsManager.swift | 1704 -> 1758 | the five existing mobile cancel events belong beside the other subscription-funnel emitters; splitting the analytics manager is a separate refactor.

…-2171)

Web and macOS now ask why, then show the existing 50%-off retention offer,
then confirm, reusing the mobile DELETE /v1/payments/subscription payload,
the retention-offer endpoints and the five existing cancel events with a
platform property. The added steps sit behind cancel_flow_parity
(CANCEL_FLOW_PARITY_ENABLED), off until David's render/copy approval; with
it off both platforms keep today's plain confirm / Manage-only paths.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T13:09:01.321811Z b12ee62 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui 6b0f5f55c90ee6c94d99612411c28e99c20eb4c9. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b0f5f55c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

trackCancelConfirmed(reason, Boolean(trimmed));
try {
const result = await cancelSubscription({ reason, reasonDetails: trimmed });
if (result?.status === 'success' || result?.cancel_at_period_end) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize the cancellation endpoint's actual success status

With the parity flag enabled, a successful cancellation never reaches the done screen: both success branches of backend/routers/payment.py::cancel_subscription_endpoint return { "status": "ok", "message": "Subscription scheduled for cancellation." }, without cancel_at_period_end. This condition therefore treats the successful response as an error, displays its message as an error, and skips onSubscriptionUpdate(), leaving the surrounding billing UI stale even though Stripe has scheduled cancellation. Accept the endpoint's ok status and cover that actual response shape in the flow test.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@/Volumes/scratch/tmp/opencode/reply-codex.md

@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui 9856449707288a72d3437edbb5d32ddd44b0ee19. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9856449707

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1261 to +1265
.sheet(isPresented: $showCancelSubscriptionFlow) {
CancelSubscriptionFlowSheet(
periodEnd: userSubscription?.subscription.currentPeriodEnd,
onClose: { showCancelSubscriptionFlow = false },
onSubscriptionChanged: { loadSubscriptionInfo() })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the cancellation sheet presenter outside the conditional row

On macOS, a successful cancellation calls onSubscriptionChanged(), which refreshes userSubscription through loadSubscriptionInfo(). The cancellation endpoint persists cancel_at_period_end = true, so that refresh makes the enclosing cancelAtPeriodEnd != true condition false and removes the button hosting this .sheet. This dismisses the flow's success screen as soon as billing finishes refreshing, rather than leaving the confirmation visible until the user selects Done. Attach the sheet to a stable ancestor outside the conditional cancellation entry.

Useful? React with 👍 / 👎.

Resolves the feature-flag registry conflict by keeping both sides: main's
CONVERSION_BUNDLE_ATTRIBUTION_ENABLED and PUSHER_RECORD_SEAL_PENDING_RETRY_BOUND_ENABLED
rows plus this branch's cancel_flow_parity row. No source file this PR
touches was changed on main, so the approved render/copy packet is unaffected.
@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui 4b91d9a672fb21a0648d83fd35689beb8461f631. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

…-2171)

The macOS cancel sheet was attached to the Cancel Subscription button inside
`cancelSubscriptionEntry`. A successful cancellation refreshes the
subscription, which sets `cancelAtPeriodEnd` and removes that row — so the
presenter disappeared and SwiftUI dismissed the sheet before the user could
read the done screen.

Present the sheet from `planUsageSection` instead, through a small
`presentsCancelSubscriptionFlow` modifier, so the done screen stays until the
user explicitly dismisses it (Done, Keep, or close) and then shows the
refreshed billing state. Entry visibility moves to `showsCancelEntry` with the
same flag/cancelAtPeriodEnd rules, so the default-off path is unchanged.

Tests: CancelSheetLifecycleTests pins the entry-visibility transition, the
presentation placement, and the done-screen hold. Renders are byte-identical
to the committed evidence packet.

Failure-Class: FC-presenter-removed-with-its-conditional-row
@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui da2b7e3ba48b140db645595c5859b72f49decbb7. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

Resolves the feature-flag registry conflict by keeping both sides: main's new
CONVERSATION_NOTES_SCALED_DETAIL_ENABLED, EPISODE_COMPOSER_*,
RECORD_FRESH_MERGE_ENABLED, RECORD_NOTES_EAGER_ENABLED,
RECORD_FOLLOWUP_WAKE_ENABLED and RECORD_SEAL_WAKE_PRESERVED_ENABLED rows plus
this branch's cancel_flow_parity row. No source file this PR touches was
changed on main, so the approved render/copy packet and the SCA-2364
stable-presenter lifecycle fix are unaffected.
@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui 298b4aa4e8b7a210b4116ba60b710a00d48f44f7. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

Brings in the upstream guard repair (#25372) that unblocks this PR's backend
unit run, plus the episode default-context and pusher codec work. Resolves the
feature-flag registry conflict by keeping both sides: main's new
EPISODE_COMPOSER_DEFAULT_CONTEXT_ENABLED row plus this branch's
cancel_flow_parity row. No source file this PR touches was changed on main, so
the approved render/copy packet and the SCA-2364 stable-presenter lifecycle fix
are unaffected.
@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui 4d2d14427bc2fcec38aaa08b80fc26466f80acf0. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

Resolves the feature-flag registry conflict by keeping both sides: main's new
HOME_FEED_ENABLED row plus this branch's cancel_flow_parity row. No source file
this PR touches was changed on main, so the approved render/copy packet and the
SCA-2364 stable-presenter lifecycle fix are unaffected.
@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui b12ee6247458c9ac135204d94b6c3fbaf8f7cd23. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b12ee62474

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

cancelSubscription: ((String, String?) async throws -> Void)? = nil
) {
self.periodEnd = periodEnd
self.loadOffer = loadOffer ?? { try? await APIClient.shared.getRetentionOffer() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the failed retention-offer lookup before falling back

When the macOS retention-offer request fails, try? silently converts the error to nil, and loadOfferIfNeeded() presents the same no-offer state used for genuinely ineligible subscriptions. Consequently, an endpoint or connectivity failure can suppress retention offers without any fallback telemetry distinguishing it from normal eligibility decisions. Keep cancellation reachable, but catch the lookup failure and call DesktopDiagnosticsManager.recordFallback with bounded fields and a degraded outcome before returning nil, as required by the macOS guide.

AGENTS.md reference: desktop/macos/AGENTS.md:L38-L39

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment /approve-ui e0922f8370aebd2ef4eb1e3a20acac8764a55c0c. Each new head needs a fresh approval. To withdraw it, comment /reject-ui <full head SHA>. Non-UI work and unrelated access are unaffected.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant