Skip to content

Teams: Native (iOS) Platform Scope

Product: Teams Feature: Web-only management/billing boundary for the native (iOS) Teams experience Status: SHIPPED — merged to master via PR #1828 (merge commit ecbfc145e, 2026-07-27). Not yet in a tagged release (latest tag v4.2.0 predates this commit). Date: 2026-07-27 Owner: Josh Lockhart


1. Summary

A native (iOS) Teams user can fully use their team — view team progress, the team feed, the Leaderboard, and Members — but cannot manage it. Team creation, plan/seat management, and billing are web-only, permanently, not a temporary rollout gap. This is the boundary's third and final revision: the first two shipped a disabled-but-visible checkout form (real pricing, a working seat stepper, a live trial-start button) reachable by any native user who navigated to /teams/new, and a "Visit objectuve.com on the web to start a team" message that named the web as a destination — a copy pattern this codebase's App Store guideline 3.1.1 review explicitly rejects (see §6). This revision replaces both: the create flow is unreachable on native before it renders, and every remaining boundary string is destination-free.

2. What ships on native vs. web-only

Native member-first home (TeamHomeView.vue) leads with the team's collective progress, the team feed, and a 2-tile grid (Leaderboard, Members). Two things are suppressed on native, permanently:

  • TrialStatusBanner — owner-only trial/billing status banner. Gated by isOwner && bannerState && !isNative in both TeamHomeView.vue and TeamSettingsView.vue.
  • TeamBillingTab — the owner Billing tab's content. On native, the tab still exists (so an owner looking for billing finds an explanation where they'd expect it), but its panel renders NativeBoundaryCard instead of the real billing UI.

The Challenges tile was removed from the "This week" grid on both web and native — not a native-only change. It pointed at no route (disabled, opacity-60, no @click); Desi's audit found no team-challenge surface anywhere in the codebase (no /team-challenges route or view). Removing a dead-end tile was scoped independently of the native boundary work and shipped in the same PR.

A member can, natively: view the team, its members, its collective progress, its feed, its challenges progress display, and the leaderboard. A member and an owner alike cannot, natively: create a team, or manage/administer it (billing, seats, plans). This is unchanged from the original product rule — the boundary work only closes the gap between that rule and what the UI actually enforced.

3. Hide, don't show-disabled

Decision: every owner-only management/billing affordance is hidden on native, not rendered disabled. This replaces the prior pattern of scattered grey, unclickable controls implying "coming soon."

Rationale: a control a native user can never action is a permanent dead-end, not a temporary state — showing it disabled promises a future that doesn't exist. Members already never saw owner controls, so hiding is invisible to them; the only user-visible change is for owners, who get one calm, intentional explainer (NativeBoundaryCard) in place of a field of disabled buttons. One explainer answers "where did billing go?" once, instead of several dead controls each implying something is broken or temporarily unavailable.

4. Enforcement — create-team boundary

Team creation is blocked at three layers, in order of how early they intercept a native user:

  1. Router-level (primary). beforeEnter on the /teams/new route redirects native traffic to /dashboard before CreateTeamView.vue ever renders (ionic_frontend/src/router/index.ts:395-400, PR #1825, OBJ-1808). A native user cannot reach the create-team form, its pricing, or its trial-start button at all — this closed a real prior exposure where a native user could reach a fully live checkout form before any guard fired.
  2. Entry-point suppression (defense-in-depth). TeamSettingsView.vue's member-facing "Start a team" link only renders when !team && !isNative — natively, the no-team empty state renders NativeBoundaryCard instead (see §6) and never links to /teams/new.
  3. Submit-guard toast (defense-in-depth, unreachable in normal use). CreateTeamView.vue's handleStartTrial checks Capacitor.isNativePlatform() before submitting and shows a toast instead of starting checkout. Since the router guard above already makes this view unreachable on native, this only fires if a future change removes the router guard without revisiting this file — it is a backstop, not the primary control.

5. Enforcement — billing boundary

TeamBillingTab.vue contains five handlers that either call the Stripe API or change who holds billing authority for the team. None of them check platform — the file has zero occurrences of isNative or Capacitor — so the boundary depends entirely on the mount gate below, not on any per-control guard. In reading order (top to bottom of the template):

  1. Resubscribe (grace/canceled subscriptions only) — handleResubscribe (ionic_frontend/src/components/teams/TeamBillingTab.vue:304) → useTeamResubscribeSTART_TEAM_CHECKOUT_MUTATIONTeams::StartTeamCheckoutStripeService.create_team_checkout_session (rails_api/app/interactions/teams/start_team_checkout.rb:33) — the same interaction CreateTeamView.vue uses to start a brand-new team's checkout. This is a live Stripe Checkout redirect, the same class of control the create-team boundary (§4) exists to keep off native.
  2. Change seatshandleSaveSeats (:333) → ADJUST_TEAM_SEATS_MUTATIONTeams::AdjustTeamSeatsStripeService.update_team_seat_count (rails_api/app/interactions/teams/adjust_team_seats.rb:21). This was the original pre-remediation 3.1.1 exposure: before the mount gate below shipped, an owner could reach Team Settings → Billing → Change seats → Save and mutate a live Stripe subscription with no platform check anywhere.
  3. Transfer billing ownershiphandleTransfer (:356) → TRANSFER_TEAM_BILLING_OWNERSHIP_MUTATIONTeams::TransferTeamBillingOwnership (rails_api/app/interactions/teams/transfer_team_billing_ownership.rb). This one doesn't call the Stripe API directly — it reassigns Team#billingOwner and swaps owner/admin TeamMembership roles in a DB transaction — but it changes who holds billing authority over the other four controls, so it belongs in this inventory even though it isn't itself a Stripe call.
  4. Stripe billing portal ("Manage in Stripe") — handleOpenBillingPortal (:320, via the shared useTeamBillingPortal composable) → START_TEAM_BILLING_PORTAL_MUTATIONTeams::StartTeamBillingPortalStripeService.team_billing_portal_url (rails_api/app/interactions/teams/start_team_billing_portal.rb:21). The same composable also backs TrialStatusBanner's CTA in TeamHomeView.vue:359,377 and TeamSettingsView.vue:318,329 — but both of those call sites sit behind the banner's own !isNative condition (§2). This occurrence, inside TeamBillingTab, carries no gate of its own.
  5. Cancel subscriptionhandleCancelSubscription (:398) → CANCEL_TEAM_SUBSCRIPTION_MUTATIONTeams::CancelTeamSubscriptionStripeService.cancel_subscription (rails_api/app/interactions/teams/cancel_team_subscription.rb:22).

No other Stripe-reaching or billing-authority-reaching handler exists in the component. (The original ask that prompted this section named three — seats, transfer, cancel; walking the remaining two template sections, Resubscribe and Payment & invoices, surfaced the checkout redirect and the billing-portal redirect as well.)

Single enforcement mechanism. All five depend on one gate: TeamSettingsView.vue:110-117 renders NativeBoundaryCard when isNative and only mounts <TeamBillingTab v-else> otherwise. This is deliberate, not an oversight — an in-file comment in TeamBillingTab.vue:33-37 states the rule explicitly ("already gates this whole tab... No internal native branch needed here (one gate, not two)"), repeated at :299-300 for the resubscribe panel specifically. TeamSettingsView.spec.ts:175 ("owner on native: hides the TrialStatusBanner and renders the boundary card instead of TeamBillingTab") covers the gate — it asserts [data-testid="billing-tab"] does not exist when isNative is true.

Blast radius. Because the gate is single, external, and unconditional, its removal or bypass exposes all five controls at once, not incrementally: a live Stripe checkout redirect, a live seat-count mutation, a billing-ownership handoff, a Stripe portal redirect, and a subscription cancellation would all become reachable with no platform check anywhere behind them. Any future edit to TeamSettingsView.vue's native branch (lines 108-118) — including one that looks like a harmless refactor of the tab's v-if/v-else — needs the same review scrutiny as removing a guard outright, because removing it removes all five controls at once, not one.

6. Final approved copy (verbatim)

All three destination-free (no web/browser/URL named) and "yet"-free, approved by the owner 2026-07-27:

  • No-team empty state (TeamSettingsView.vue, native, no team on the account):
    • Title: "No team on this account"
    • Body: "When an owner adds you to a team, its progress, challenges, and leaderboard land right here."
    • Secondary CTA: "Keep building your own streak"/dashboard
  • Owner Billing-tab boundary card (TeamSettingsView.vue, native, Billing panel):
    • Title: "Plans, seats & billing"
    • Body: "Managed outside the app, so this space stays focused on your team — progress, challenges, and standings."
    • Checklist (what an owner can do in the app): "Add and remove members", "Organize sub-communities", "Send invites — all here in the app"
  • Create-team submit-guard toast (CreateTeamView.vue, defense-in-depth):
    • "Teams are set up by an owner, not from the app."

Standing rule — App Store guideline 3.1.1: no in-app copy may name the web, a browser, or a URL as a destination for a purchase or setup action. A prior revision of this exact boundary shipped "Visit objectuve.com on the web to start a team." and had to be reverted for violating it. Every string above avoids this by construction (no "web," "browser," "objectuve.com," or any URL) — any future edit to this surface's copy must preserve that property before shipping.

7. Product rule (unchanged, now enforced end-to-end)

A team member can perform team-specific actions natively: view the team, its members, its progress, its challenges, and the leaderboard. Team management, administration, and billing are web-only, permanently — not a "coming soon" state for either role. Nobody, member or owner, can create a team from the iOS app.

8. Traceability

  • UI-SPEC: OBJ-1811 (Desi), attached UI-SPEC.md + light/dark mockups for the member home, billing boundary card, and no-team empty state.
  • Implementation: OBJ-1813 (Codi), merged as PR #1828 (ecbfc145e).
  • Router guard: OBJ-1808, merged as PR #1825 (d445b47a5).
  • Component: ionic_frontend/src/components/teams/NativeBoundaryCard.vue — one shared component, two shapes: bullets present renders a flat card with a checklist and no CTA (the billing panel); ctaLabel/ctaTo present renders a glass card with a single secondary CTA and no checklist (the no-team empty state).
  • Billing boundary enforcement inventory (§5): OBJ-1837 (Dori), follow-up from OBJ-1807/OBJ-1814.

Last updated: 2026-07-28

Loading…