Skip to content

Phase 7 — Teams PRD

Status: ✅ Shipped — GA live in production on tag v4.0.3 (2026-07-15). V1.1/V1.2 scope (bulk CSV invite, admin analytics dashboard, accountability pair auto-matching, Slack/MS Teams integration, per-team branding) remains backlog — see milestone narrative. Phase: 7 (Apr 2027+) Owner: Josh Lockhart Last updated: 2026-07-21 Supersedes (for Teams scope): docs/product/phase-7-monetization-teams-prd.md (which now scopes to Supporter tier + cross-cutting billing infra)


1. Executive Summary

Teams is Objectuve's first B2B revenue line. It introduces a new Team aggregate — a paid, private, multi-community workspace for coaching groups, corporate wellness teams, and school cohorts — three ICPs we design for in parallel, with coaching groups leading the launch.

A Team owns billing and seats; it contains one or more private Communities that members self-select into. Small coaching cohorts will typically run with a single default Community (collapsing the model back to a familiar shape). Larger Teams (corporate wellness, school cohorts) will run multiple sub-communities — "Running Crew," "Meditation Circle," "Q2 Sales Cohort" — so members can opt into the accountability circles that matter to them without admins force-assigning everyone everywhere. The model is closer to "Slack workspace → channels" than to a single flat group.

The business case is not "extract more from individuals." Per MISSION.md, "personal progress should never be behind a paywall." Teams revenue is the funding mechanism that keeps the individual product free forever. The 12-month North Star is 10–30 paying teams generating $2K–$8K MRR; the 3-year target is 200–800 teams generating $30K–$100K MRR.

V1 MVP scope (5 features, deeply specified in §7):

  1. Billing & Subscriptions — Clerk Billing on top of existing Stripe infrastructure; seat-based at the Team level; Team Owner pays once for all sub-communities
  2. Team + Sub-Community Architecture — new Team aggregate, TeamMembership (the seat), has_many :communities; every Team auto-provisions a default Community; admins can create additional sub-communities
  3. Private Team Invites & Self-Select Browser — invite to the Team; members land in a sub-community browser to self-select which communities to join (default community auto-joined)
  4. Team Goals (Collective Progress) — collective goals scoped to a Team (cross-community) OR to a single sub-community; per-member contribution aggregation respects data boundary
  5. Team Leaderboards — points engine (events +5, milestones +15, completions +50, posts +3, encouragements +2), weekly/monthly/all-time, scoped to Team OR sub-community; anti-addictive notification policy

V1.1+ shape-only scope (§8): custom badges & challenges, team admin dashboard & analytics, accountability pair auto-matching, manager view, priority support, SSO/SAML (deferred to enterprise tier), Slack/MS Teams integration, light per-team brand customization, sub-community templates.

Two material decisions resolved in this PRD:

  • Pricing: Documented $5 (roadmap) vs $9 (pricing-philosophy) conflict. Recommends $7/user/month or $70/year with 14-day free trial. See §4.
  • Data model: Recommends a separate Team aggregate that has_many Communities (not extending Community 1:1), to support the multi-sub-community use case. See §5.

Brand-defining constraint: strict data isolation. Team admins see only team-scoped activity. Personal goals, mood logs, journal entries, and coach conversations are never visible to admins, never exported, never aggregated. See §6.

Success gate for V1 launch: Closed beta with 5 hand-picked coaching groups validates the data-boundary contract, billing flow, and leaderboard mechanics before public pricing-page launch.


2. Strategic Context

Why Teams, why now

Phase 6 (Growth & Virality) builds the acquisition funnel; Phase 7 monetizes it. Without Teams, Objectuve's only revenue path is the Supporter tier ($3/mo cosmetic perks), which by design will not scale to operational sustainability. Per MISSION.md:

Revenue comes from Teams (organizations) and optional Supporters (cosmetic perks only). This is non-negotiable, even as we scale.

Teams is the lever that converts our Public Benefit Corporation commitment from aspiration to ledger reality.

North Star alignment

From docs/product/north-star.md:

HorizonTeamsAvg seatsMRR
12mo (Apr 2027)10–306–10$2K–$8K
3yr (Apr 2029)200–8008–12$30K–$100K

The 12-month target is intentionally modest: this is a B2B motion built on top of a B2C product, sold by a small team, with no outbound sales budget. The funnel is inbound + word-of-mouth via existing community admins.

What we are not building

Teams is not a Slack competitor, not a project-management replacement, not an HR analytics platform. It is the accountability layer for groups who already share a goal — a coach with clients, a wellness lead with employees, a teacher with a cohort. The product fits in ~10 minutes of a member's day, same as the individual app. See §9 for the constraints this places on every Teams feature.


3. Lead ICP and Personas

Lead ICP — Coaching groups / accountability cohorts

Size: 5–20 seats Buyer = User: the coach owns billing and the team Why first:

  • Closest fit to existing Community usage (private group, small, goal-oriented)
  • Single decision-maker (no procurement)
  • Highest willingness to pay per seat (coaches charge $100–$500/mo per client)
  • Existing organic discovery via Josh's network and ally referrals
  • Lowest compliance bar — no SOC 2, no HIPAA, no SSO required for V1

Examples: Life coaches with 8–12 clients; running clubs with weekly check-ins; sobriety accountability circles; PhD writing groups.

Concurrent design targets

We design data model, permissions, and admin UX to not preclude these two ICPs, even though they ship in later waves:

Corporate wellness teams (V1.1):

  • Size: 10–50 seats
  • Buyer: HR / People Ops; users: employees
  • Adds: bulk CSV invite, manager view, light analytics, Slack integration
  • Compliance: needs aggregate-only reporting (data boundary §6 already covers this), eventual SOC 2 readiness

Schools / cohorts (V1.2):

  • Size: 20–100 seats, seasonal
  • Buyer: teacher / bootcamp lead; users: students
  • Adds: invite expiration matched to semester, archive-at-end-of-term, cohort copy
  • Compliance: FERPA considerations if K-12 (defer K-12 to V2; college and bootcamp are OK at V1)

Personas

PersonaRoleKey needsWhat they don't need
Team OwnerPays the bill, sets up the Team and its sub-communitiesBilling transparency, seat management, transferable ownership, ability to provision/archive sub-communitiesDay-to-day moderation tooling
Team AdminDelegated admin (e.g., assistant coach, HR partner)Invite/remove members across the Team, create sub-communities, see team-wide activityBilling access
Sub-Community LeadAdmin/moderator of one sub-community within a Team (e.g., "Running Crew lead")Manage their sub-community feed, challenges, and collective goalsCross-community visibility outside their sub-community
Team MemberThe accountability subjectBrowse + self-select into sub-communities, contribute to goals, see leaderboards scoped where they participateAdmin tooling, other members' private data, sub-communities they didn't join
External CoachNon-billable observer (e.g., a coach's coach)Read-only view scoped to specific sub-communitiesPersonal data of members; sub-communities they weren't granted access to

Anti-personas (V1)

  • Enterprise IT buyers demanding SSO/SAML on day 1 — defer to enterprise tier
  • HIPAA-regulated providers (therapists, clinical groups) — defer to 3-yr enterprise tier
  • Public communities that want to "upgrade in place" — Teams is private by definition; public communities stay free

4. Pricing Decision

The conflict

SourceTeams priceAnnual
docs/product/roadmap.md (Phase 7 header)$5/user/month$50/user/year
docs/product/pricing-philosophy.md$9/user/monthnot specified

Both documents are checked into master. This PRD resolves the conflict.

Comparison

Dimension$5/user/mo$7/user/mo (recommended)$9/user/mo
Annual price (w/ ~17% discount)$50$70$90
Seats for $2K MRR400286222
Seats for $8K MRR1,6001,143889
Avg team of 8 → MRR contribution$40$56$72
30 teams × 8 seats avg$1,200/mo$1,680/mo$2,160/mo
Competitive frameBelow Strava Premium ($11.99/yr-per-user / month equiv)Below Slack ($7.25)Above Slack, below Asana ($10.99)
ICP fit — coaching groups (price-sensitive)Easy yesEasy yes"Worth it?" friction
ICP fit — corporate wellness (budget-led)"Why so cheap?" trust hitCredibleCredible
Margin headroom for Stripe (~3% + $0.30 / seat / mo)TightComfortableComfortable

Recommendation

$7/user/month or $70/user/year (effective ~17% annual discount), with a 14-day free trial (no card required for trial start; card required to convert).

Shipped (2026-07-13, OBJ-1401). create_team_checkout_session sets payment_method_collection: 'if_required' — Stripe collects no card at checkout, matching this decision. Since no card exists, "card required to convert" is enforced via subscription_data.trial_settings.end_behavior.missing_payment_method: 'create_invoice': at trial end, Stripe issues an open invoice instead of auto-canceling or pausing the subscription. If the owner has added a card by then (via the team billing portal, startTeamBillingPortal mutation), the invoice auto-charges and the subscription converts to active. If not, the invoice stays unpaid and the team falls into the existing past_duegracecanceled lifecycle (§7.1) — the same path as a failed charge. See GraphQL Reference § Teams Billing.

Rationale:

  • Clean math: $7 × 10 seats = $70/mo, easy to forecast
  • Below Slack's $7.25 anchor — coaches' clients can do the math
  • Above the $5 "too cheap" perception threshold that signals "this is a hobby tool" to corporate buyers
  • Annual discount captures cash-up-front for coaches who charge clients annually
  • 14-day trial converts intent to activation without procurement friction

Decision owner: Josh Due: before pricing-page copy is drafted (Phase 7 sprint 1)

Action items downstream of this decision:

  • Update docs/product/roadmap.md line ~989 ($5 → $7)
  • Update docs/product/pricing-philosophy.md (Teams section)
  • Update docs/product/revenue-projections.md if it carries either number
  • Create one Plan row per tier (monthly, annual) in Rails seed

5. Data Model Decision

The driving requirement (revised)

Teams must support a parent organization that owns many private sub-communities which members self-select into. Examples:

  • Small coach (1 community): "Sarah's Coaching Cohort" Team contains one default Community where all 8 clients participate. The multi-community surface stays hidden by default — UX collapses to the familiar single-group experience.
  • Corporate wellness (3–10 communities): "Acme Wellness" Team contains "Running Crew," "Meditation Circle," "Quit Smoking Squad." Each employee browses the directory at onboarding and joins the 0–N communities relevant to them. A few employees may join none and just track personal goals while still counting toward seat count.
  • School cohort (5+ communities): "Spring 2027 Bootcamp" Team contains "Cohort A," "Cohort B," "Project Group 1," "Capstone Reviews." Students join based on assignment + interest.

This rules out Option A — extend Community 1:1 with Team (the recommendation in the prior draft of this PRD). A flat 1:1 model forces an admin in a 50-employee company to choose between one mega-community (poor signal-to-noise) or splintering billing across multiple Communities (a procurement and UX nightmare).

The new options

Option C — Separate Team aggregate; Team has_many :communities (recommended)

A Team is a new aggregate root that owns billing, seats, and a directory of one or more Communities. Existing Community model is reused mostly as-is — it gains an optional team_id FK and an is_default flag for the auto-provisioned community per Team.

Option D — Team aggregate with sub-Teams (nested Teams)

Same as C but allow a Team to contain other Teams. Adds nesting flexibility but doubles the access-control surface and has no validated use case in our V1 ICPs.

Tradeoffs (revised)

DimensionOption A (old rec — flat)Option C (new rec — Team + many Communities)Option D (nested Teams)
Supports multi-sub-community use case❌ — would require splintering billing✅ — native✅ — overkill
Small-team UX (single community)✅ — already 1:1✅ — auto-provisioned default community; multi-community UI hidden until 2nd is created✅ but extra clicks
Migration cost1 migration, ~6 cols2 new tables (Team, TeamMembership), team_id FK on Community, ~12 cols total3 new tables, recursive FK on Team
Billing surfacePer-Community subscriptionsOne subscription per Team; seat = TeamMembershipOne per leaf Team; complex aggregation
GraphQL surfaceFields on CommunityTypeNew TeamType, TeamMembershipType, TeamSubscriptionType; CommunityType gains optional team field+ TeamHierarchyType
Permissions complexitySingle role on CommunityTwo-level: Team role + per-Community CommunityMember roleThree-level; high test burden
Data-boundary enforcementSingle scope to checkTwo scopes (Team vs. Community); §6 contract must cover bothThree scopes
Risk of leaking sub-community A activity to non-member of AN/AHigh if not carefully gated — must be in §6 contractHigher
Codebase realityReuses Community as aggregateCommunity remains usable as aggregate root for free/public communities; Team is the new aggregate root for paid context. Clean separation.Adds recursive query complexity

Recommendation

Option C — separate Team aggregate that has_many Communities.

Rationale:

  • It's the only option that supports the multi-sub-community ICPs (corporate wellness, schools) without forcing admins into a bad data shape.
  • For the lead ICP (coaching groups), it collapses cleanly to a single-community experience via an auto-provisioned default Community at Team creation. The "multi-community" UX surface (sub-community browser, sub-community switcher) is hidden until the admin creates a second Community. Small teams don't pay UX tax for a feature they don't use.
  • Community model stays mostly untouched — public/free communities continue to exist as standalone aggregates (no team_id). The only required change is an optional team_id FK + is_default_for_team boolean.
  • Two-level permissions (Team role + per-Community role) are simpler than they sound: most members will have Team role = member + Community role = member for the few communities they join. Admins/Owners cascade.
  • Future enterprise tier (nested Teams via Option D) remains a clean upgrade path — Option C does not preclude it.

The trade we accept:

  • More work in §6 (data boundary contract) to define two scopes: what Team admins see vs. what Sub-Community Leads see. Worth it; the alternative is the wrong product shape.
  • More tests for permission interactions. Mitigated by a single TeamAccessPolicy service object that centralizes the rules.

Auto-provisioning behavior (critical for small-team UX)

When a Team is created:

  1. Team record created with slug, name, billing_owner_id
  2. One Community auto-created with is_default_for_team: true, name defaulted to the Team's name, team_id set, private: true
  3. Team Owner becomes admin of both Team and default Community
  4. UI shows the single-community experience by default

When admin creates a 2nd Community within a Team:

  • UI switches to multi-community mode: sidebar gains a sub-community switcher; member onboarding gains a sub-community browser step.
  • The 1st Community keeps is_default_for_team: true (members are auto-joined here at invite-acceptance; opt-out allowed).

When admin deletes the last non-default Community:

  • UI reverts to single-community mode (the default community stays).

This keeps the model expressive for large teams while invisibly simple for small ones.

Migration sketch

ruby
# rails_api/db/migrate/YYYYMMDDHHMMSS_create_teams.rb
class CreateTeams < ActiveRecord::Migration[8.0]
  def change
    create_table :teams do |t|
      t.string :public_id, null: false, index: { unique: true }
      t.string :name, null: false
      t.string :slug, null: false, index: { unique: true }
      t.references :billing_owner, foreign_key: { to_table: :users }, null: false
      t.references :team_subscription, foreign_key: true
      t.string :team_brand_color   # V1.1
      t.string :team_logo_url      # V1.1
      t.datetime :deleted_at, index: true   # acts_as_paranoid
      t.timestamps
    end

    create_table :team_subscriptions do |t|
      t.references :team, foreign_key: true, null: false
      t.references :plan, foreign_key: true, null: false
      t.string :stripe_subscription_id, null: false, index: { unique: true }
      t.string :stripe_customer_id, null: false
      t.integer :seat_count, null: false, default: 1
      t.string :status, null: false, default: "trialing"
        # trialing | active | past_due | grace | canceled
      t.datetime :current_period_end
      t.datetime :trial_ends_at
      t.timestamps
    end

    create_table :team_memberships do |t|
      t.references :team, foreign_key: true, null: false
      t.references :user, foreign_key: true, null: false
      t.string :role, null: false, default: "member"
        # owner | admin | member | external_coach
      t.datetime :joined_at, null: false
      t.datetime :deleted_at, index: true   # paranoid; preserves leaderboard history as "Former member"
      t.timestamps
      t.index [:team_id, :user_id], unique: true, where: "deleted_at IS NULL"
    end

    add_reference :communities, :team, foreign_key: true   # NULL for public/free communities
    add_column :communities, :is_default_for_team, :boolean, default: false, null: false
    add_index :communities, [:team_id, :is_default_for_team]
  end
end

Notes:

  • TeamMembership is the seat. Billing's seat_count always matches the count of active TeamMemberships (excluding external_coach role, which is configurable as billable / non-billable per Team).
  • CommunityMember (existing) remains the per-Community membership; a user has 1 TeamMembership plus 0..N CommunityMember rows within Communities of that Team.
  • Community.team_id is nullable so existing public/free Communities are unaffected by migration.
  • A TeamAccessPolicy service object encapsulates the rule: "User U can see Community C if C is public, OR (C belongs to Team T AND U is a member of T AND (C is default-for-team OR U is a CommunityMember of C OR U has Team-admin role))."

Decision owner: Josh Due: before Sprint 1 migration is written


6. Data Boundary and Privacy Contract

This section is brand-defining. Every Teams feature in this PRD inherits these rules.

The principle

Strict isolation. A user's personal goals, mood logs, journal entries, personal coach interactions, friend graph, and badges earned outside team scope are invisible to team admins. Full stop. Members trust Objectuve with personal-development data precisely because we don't sell it, expose it, or aggregate it for managers. Teams cannot become the trapdoor that violates that trust.

Two visibility scopes (because of multi-community architecture)

Team admins and Sub-Community Leads see different slices of activity. The general rule: you see only what happens in your scope.

RoleSees
Team Owner / Team AdminTeam-wide membership, seat count, billing; aggregate activity across all sub-communities; sub-community-level summaries (e.g., "Running Crew: 18 members, 142 check-ins this week"); cannot see message-level activity inside a sub-community they didn't join
Sub-Community Lead (CommunityMember role admin/moderator)Full activity within their sub-community (posts, members, leaderboard, collective goals); no cross-community visibility outside
Team Member (CommunityMember of sub-community X only)Full activity within X; no visibility into sub-communities they didn't join
External CoachRead-only within explicitly granted sub-communities; no Team-wide view

Critical: a Team Admin does NOT auto-see message content inside sub-communities they didn't join. To moderate a specific sub-community, a Team Admin can self-join (visible action; member notification) or use a "join as moderator" promotion. No silent observation.

What anyone in admin/lead role CAN see

  • Team-tagged goals (goals explicitly contributed to a collective team goal, OR goals the member created within the team scope)
  • Challenge participation (entered / completed / abandoned)
  • Leaderboard points earned within their scope — never lifetime totals
  • Check-in counts on collective goals
  • Last-active date (truncated to day, not time)
  • Member-set status: opted in / paused / left

What admins CANNOT see (ever)

  • Personal goal list (goals outside team scope)
  • Mood logs
  • Journal entries
  • Coach conversation history or coach configuration
  • Badges earned outside team
  • Friend / ally graph
  • IP addresses, device info, location
  • Lifetime XP, lifetime streaks, lifetime check-in counts

Export contract

CSV export from the team admin dashboard is limited to team-scoped activity:

Allowed columnsDisallowed columns
Member display name, joined-at, last-active date (day)Real name, email, phone
Team-goal contribution counts, challenge resultsPersonal goal titles, descriptions
Team leaderboard points (period-scoped)Lifetime XP, lifetime streaks
Custom badges earned within teamAll other badges

Email addresses are not in the CSV. If an admin needs to contact a member off-platform, they must already have the email from invitation; the platform does not re-expose it.

Offboarding

When a member leaves a team (or is removed):

  • Their team-scoped historical contributions remain attributed as "Former member" (anonymized display) for leaderboard integrity
  • Their personal account, personal goals, and personal data are entirely untouched
  • They lose access to the team feed and team-only goals immediately
  • They retain a record of their participation in their own profile ("Previously a member of [Team name]") — visible only to them

Member-facing transparency

In settings → privacy → "What [Team name] can see," a member sees a list mirroring this contract verbatim. No legal-ese; the same plain-English bullets. This is the trust artifact.

Future SOC 2 readiness

This contract is upstream of SOC 2. The control surface is small (one query path: TeamScopedActivityQuery) and audit-friendly. When we pursue SOC 2 (post-Series-A, ~3yr horizon), the work is implementing the audit trail and pen-test, not rewriting permissions.


7. V1 MVP Scope — Deep Specification

7.1 Billing & Subscriptions (Clerk Billing on Stripe)

Goal: A coach or wellness admin can create a Team, get a default sub-community spun up automatically, and start inviting members in under 3 minutes.

User stories:

  • As a new Team creator, I can sign up for the Teams plan and have my first sub-community ready immediately.
  • As an existing private-community owner, I can convert my community into the default community of a new Team and start inviting more members.
  • As a Team Owner, I can add or remove seats and see proration on my next invoice.
  • As a Team Owner, I can transfer billing ownership to another Team admin.
  • As a Team Owner, I can cancel my subscription and understand what happens to my Team and its sub-communities.

Flow — new Team creation (happy path):

  1. User visits /teams/new (from pricing page CTA or in-app upgrade banner)
  2. Plan selector shows Monthly ($7/seat) and Annual ($70/seat, "Save ~17%") with seat quantity stepper (default 5, min 1, max 200 for V1)
  3. Team name input (also becomes default Community name; admin can rename later)
  4. "Start 14-day free trial" CTA → Clerk Billing checkout (Stripe-backed)
  5. On checkout.session.completed webhook → Teams::ProvisionTeam interaction runs:
    • Creates Team record + TeamSubscription (status trialing, trial_ends_at = now + 14.days)
    • Creates TeamMembership for the Team Owner (role owner)
    • Auto-provisions default Community: Community.create!(team: team, is_default_for_team: true, private: true, name: team.name, creator: owner) + CommunityMember for owner with role admin
    • Triggers TeamUpgradedJob (welcome email + in-app celebration)
  6. Owner lands on Team Settings → Invite Members tab with empty state CTA

Flow — convert existing private community into a Team:

  1. Community admin visits Community Settings → Upgrade to Teams
  2. Same plan selector flow as above
  3. On webhook: Teams::ProvisionTeam creates Team + Subscription; existing Community is attached via community.update!(team: team, is_default_for_team: true); existing members get TeamMembership rows (role member, original CommunityMember role preserved)
  4. Members receive notification: "Your community is now part of a Team. Nothing has changed for you — just new tools your admin can use."

Flow — failed payment:

  1. invoice.payment_failed webhook → TeamSubscription.status = "past_due"
  2. Email Team Owner immediately + day 3 + day 6
  3. Team continues to work normally (full features) for 7 days
  4. Day 7: TeamSubscription.status = "grace" → Team enters read-only mode across all its sub-communities (existing members can view; no new invites, no challenge creation, no leaderboard updates, no new sub-communities)
  5. Day 14: Teams::PaymentFailedJob's daily sweep transitions the subscription out of grace:
    • Stripe subscription is canceled (StripeService.cancel_subscription)
    • TeamSubscription.status = "canceled"
    • Teams::TeamCanceledJob fires: logs the transition and emits a team_canceled PostHog event (owner-facing notification copy is a follow-up, not yet written)
    • Cancellation is terminal — reactivation requires a fresh checkout (same pattern as Teams::ProvisionTeamSubscription), not a re-subscribe link or automatic restore
    • Undefined today: what happens to the Team's Communities and members after cancellation. No archival, no default-community-to-free conversion, and no access enforcement exist in the codebase — TeamAccessPolicy grants are role-only and never check subscription status, so a canceled Team's data and member access are unchanged until someone decides what "canceled" should mean operationally

Webhook events handled:

EventAction
checkout.session.completedProvision subscription
customer.subscription.updatedSync seat count, status, period end
customer.subscription.deletedLogged no-op (Teams::ProcessSubscriptionLifecycleEvent) — team cancellation is already app-driven, so local state is reconciled before this event lands; the handler exists to keep team-owned events off the personal-Supporter path
customer.subscription.trial_will_endEmail owner 3 days before trial ends
invoice.paidMark active, clear past_due flags
invoice.payment_failedMark past_due, start grace timer
customer.subscription.pending_update_appliedConfirm seat add/remove

Status note (2026-07-13, OBJ-1401): checkout (step 4) collects no card (payment_method_collection: 'if_required'). At trial end, trial_settings.end_behavior.missing_payment_method: 'create_invoice' issues an open invoice rather than canceling the subscription outright — a card added late (via the team billing portal, before or after trial end) settles that invoice and converts the team via the invoice.paid handler described below. No card by day 14 flows into the existing failed-payment lifecycle below unchanged.

Status note (2026-07-13, OBJ-1411): the invoice.paid row above is now implemented. Webhooks::StripeController resolves a team invoice.paid event via TeamSubscription.find_by(stripe_subscription_id:) and routes it to Teams::ProcessInvoicePaid, which transitions trialing/past_due/graceactive, refreshes current_period_end from Stripe on every event including renewals, and fires a trial_converted PostHog event exactly once per conversion. canceled is deliberately not a recovery source state — reactivation after cancellation requires a fresh checkout, the same pattern as re-subscribing in Teams::ProvisionTeamSubscription. Team events never reach Billing::ProcessStripeWebhook (that dispatcher stays Supporter-only). See PR #1515.

Status note (2026-07-15, OBJ-1412): the invoice.payment_failed row above is also now webhook-driven, not clock-inferred — Teams::ProcessInvoicePaymentFailed moves trialing/activepast_due and stamps current_period_end = Time.current so the 7+7-day grace/cancel timers start from the moment of failure. This replaces the OBJ-1411 note's earlier claim that a daily Teams::TrialExpiredJob sweep could flip a genuinely-paid sub to past_due: that job is now a safety-net alarm only (logs + pages Sentry if a subscription is still trialing past trial_ends_at, meaning the webhook is late or missing) and no longer writes past_due itself — see the runbook's "what T3 changed" section for the full before/after. The table's customer.subscription.deleted / auto-cancel path also changed: Teams::PaymentFailedJob#transition_to_canceled now calls StripeService.cancel_subscription before marking the local row canceled, and skips the local cancellation entirely (fails closed, retries next day) if Stripe still reports the subscription active — closing the "charged forever after cancellation" gap the original webhook table didn't have to account for. All routing above happens in Webhooks::StripeController#route_event, which resolves each event against TeamSubscription first and only falls through to Billing::ProcessStripeWebhook (Supporter-only) when no team subscription matches. See PR #1539.

Status note (2026-07-16, OBJ-1472): step 4's read-only mode is now enforced, not just messaged. Before this, the shipped grace_readonly banner (TrialStatusBanner.vue) told users the team was read-only, but every write CTA it described stayed fully functional — no interaction anywhere gated on subscription.status. A shared Teams::Concerns::RequiresWritableSubscription guard now blocks exactly three interactions with a team_read_only GraphQL error while status == 'grace': create_team_invite ("no new invites"), create_sub_community ("no new sub-communities"), and create_collective_goal ("no challenge creation"). Matching locked-CTA states (LockedWriteButton.vue) ship on TeamMembersTab, TeamInvitesTab, and TeamSubCommunitiesTab. "No leaderboard updates" is an emergent effect, not a fourth gated write — the codebase has no admin leaderboard-config interaction to gate; the only leaderboard mutation, set_leaderboard_visibility, is a member's own "Hide me" privacy opt-out and is deliberately left writable in grace (gating a member's own privacy control would contradict the "personal progress never behind a paywall" principle). The leaderboard simply stops advancing because the invite/challenge/sub-community activity that feeds it is paused. Member-contribution actions (opt_into_collective_goal/opt_out_of_collective_goal, submit_team_pulse, join_sub_community, leave_sub_community, accept_team_invite) and all billing-recovery actions stay writable throughout grace so an owner can resolve the state. See PR #1571.

Idempotency: Stripe event ID stored on PaymentRecord; duplicate webhooks no-op. Existing Billing::ProcessStripeWebhook already handles this pattern for Supporter.

Seat lifecycle:

  • Add seat: invite sent + accepted → seat_count incremented via Stripe API → prorated charge on next invoice
  • Remove seat (member leaves): seat_count decremented at end of current period (no refund)
  • Add seat after trial: prorated immediately
  • Bulk invite via CSV (V1.1): seats reserved with 14-day hold; auto-released if unclaimed

Reuse:

  • Plan model — add rows for teams_monthly and teams_annual
  • PaymentRecord model — extend to record team subscription transactions (add nullable team_subscription_id)
  • StripeService — extend create_checkout_session to accept quantity parameter
  • Billing::ProcessStripeWebhook — extend dispatcher

New:

  • TeamSubscription model (schema in §5)
  • Teams::ProvisionTeamSubscription interaction
  • Teams::AdjustTeamSeats interaction
  • Teams::TransferTeamBillingOwnership interaction
  • Teams::CancelTeamSubscription interaction
  • TeamUpgradedJob, TeamDowngradedJob, Teams::TrialEndingJob

GraphQL surface:

graphql
mutation StartTeamCheckout($communityId: ID!, $planId: ID!, $seatCount: Int!) {
  startTeamCheckout(input: { ... }) { checkoutUrl errors }
}
mutation AdjustTeamSeats($teamSubscriptionId: ID!, $newSeatCount: Int!) { ... }
mutation TransferTeamBillingOwnership($teamSubscriptionId: ID!, $newOwnerId: ID!) { ... }
mutation CancelTeamSubscription($teamSubscriptionId: ID!) { ... }
type TeamSubscriptionType {
  id, status, seatCount, currentPeriodEnd, trialEndsAt, plan, billingOwner, community
}

Edge cases:

  • Owner removes themselves from the community → blocked; must transfer ownership first
  • Owner deletes the community → subscription canceled at period end with confirmation modal
  • Community has 12 members but seat_count drops to 10 → block; UI shows "Add 2 seats or remove 2 members to continue"
  • Refund requests — handled manually via Stripe dashboard (no in-app refund UI for V1)

Copy:

  • Trial start: "Welcome to Teams. You have 14 days to invite your team — no card needed yet."
  • Trial ending: "Your Teams trial ends in 3 days. Add a payment method to keep your team's leaderboards, challenges, and collective goals."
  • Grace mode: "Your team is in grace mode while we sort out a payment issue. Members can still see their progress — admins can fix this in Team Settings → Billing."

Success metrics:

  • Trial → paid conversion: ≥40% within 14 days
  • Time from community-creator click to invite-sent: <5 minutes (95th percentile)
  • Failed payment recovery rate: ≥60% within grace period
  • Owner billing support tickets: <5% of teams/quarter

7.2 Team Invites & Sub-Community Self-Select

Goal: A Team Owner can invite 10 members in under 2 minutes; new members land in a sub-community browser, auto-join the default community, and self-select 0–N additional sub-communities.

User stories:

  • As an admin, I can share a single Team invite link with anyone — they join the Team and pick which sub-communities to participate in.
  • As an admin, I can invite specific people by email; an email-targeted invite can optionally pre-select sub-communities ("Join Acme Wellness's Running Crew").
  • As an admin (corporate), I can paste a CSV of emails with optional sub-community assignments and send invites in bulk.
  • As an admin, I can revoke an unused invite at any time.
  • As an admin, I can promote a member to Team Admin (cross-community access) or Sub-Community Lead (single-community admin); I can remove a member from the Team entirely.
  • As an invitee, I land on a sub-community browser at first session and choose which I want to join. The default community is pre-checked but skippable for a member who only wants to track personal goals.
  • As a member, after joining I can browse and join other sub-communities at any time from the Team home.

Data model — new TeamInvite:

ruby
class TeamInvite < PublicRecord
  acts_as_paranoid
  belongs_to :team
  belongs_to :invited_by, class_name: "User"
  belongs_to :accepted_by, class_name: "User", optional: true
  # code: string (16-char base32, unique)
  # email: string, nullable (when nil = open link invite)
  # team_role: enum admin|member, default member  (owner cannot be invited; transfer via separate flow)
  # preselected_community_ids: bigint[]  (sub-communities pre-checked in the browser at acceptance; member can still opt out)
  # expires_at: datetime (default 14.days.from_now)
  # max_uses: integer, default 1 (or null for unlimited link)
  # used_count: integer, default 0
  # status: enum pending|accepted|revoked|expired
end

Invite flows:

Team link invite (default, for coaches DMing clients):

  1. Admin → Team Settings → Invite → Copy Team Link
  2. Link format: https://app.objectuve.com/join-team/{code}
  3. Recipient clicks → if signed in, accepts → lands on Sub-Community Browser (see below); if signed out, signs up via Clerk first
  4. Single-use by default; admin can toggle to multi-use with usage cap

Sub-community-scoped invite link (V1.1 — for "Running Crew lead" sharing their crew's link):

  1. Sub-Community Lead → Community Settings → Invite to this Community
  2. Generates a TeamInvite with preselected_community_ids containing just that sub-community
  3. Recipient still joins the Team, but the sub-community browser pre-checks the named sub-community

Email invite:

  1. Admin enters one or more emails (textarea, comma/newline separated); optionally pre-selects sub-communities for these invitees
  2. Each gets a TeamInvite with email and preselected_community_ids set
  3. TeamInviteMailer sends email (subject: "[Owner name] invited you to join [Team name] on Objectuve")
  4. Recipient clicks email link → sub-community browser (with pre-selections) → accepts
  5. Status: accepted on join, expired after 14 days, revoked on admin action

Bulk CSV (V1.1 — corporate wellness):

  1. Admin uploads CSV with email column (optional: team_role, display_name, communities semicolon-separated slugs)
  2. Preview shows count + any malformed rows + an "all" toggle to assign every invitee to a specific sub-community
  3. Bulk-create TeamInvite records; emails queued via Sidekiq batch
  4. Seat reservation: seats locked for 14 days; auto-released if invite expires

Sub-Community Browser (the self-select moment):

This is a new screen rendered immediately after a new member accepts a Team invite. It is the single biggest UX moment for multi-community Teams.

Layout:

  • Header: "Welcome to [Team name]. Choose the communities you want to participate in. You can join more later."
  • For each sub-community: card with name, brief description, member count, current weekly activity ("18 check-ins this week"), and a checkbox.
  • Default community is pre-checked with a small badge "Default — recommended."
  • Pre-selected sub-communities (from invite) are pre-checked.
  • "Skip for now (default only)" link at the bottom.
  • "Join selected communities" primary CTA at the bottom.

On submit, CommunityMember rows are created for each checked community.

For Teams with only one community: the browser is bypassed entirely — member is auto-joined to the default community and lands in the team feed. UI-collapse logic: if team.communities.count == 1, skip browser.

Admin controls (Team Settings → Members tab):

  • List view: name, Team role, joined date, last active (day), sub-communities joined (count, expandable list), status (active/paused)
  • Per-member actions: change Team role, remove from Team (cascade removes all CommunityMember rows + downgrades seat at period end), view team-scoped activity (no personal data per §6)
  • Pending invites section: revoke, resend, copy link
  • Bulk actions (V1.1): remove multiple, change Team role multiple

Sub-community management (Team Settings → Communities tab — new in V1):

  • List of all sub-communities in this Team
  • Create new sub-community (modal: name, description, icon/color, default-join toggle for new members)
  • Archive sub-community (preserves data, hides from browsers; un-archive within 30 days available)
  • Set/unset default community (only one default per Team)
  • View per-sub-community member count, activity level, last activity

Edge cases:

  • Invite email already has Objectuve account → log in flow → sub-community browser → accept
  • Invite email signs up with a different email (e.g., Apple Hide-My-Email) → match invite by code-in-cookie, accept and warn admin
  • Member is already in another Team (V1: allowed; V1.1: multi-Team UX polish — see V1.1 sec)
  • Member declines the default community in the browser → still becomes a TeamMembership (seat consumed), just no CommunityMember rows; can join later
  • Admin archives a sub-community while members are joined → members get notified, lose access; their leaderboard entries archive as "Former" entries
  • Last non-default sub-community is deleted → UI reverts to single-community mode (the default stays)
  • Default sub-community delete attempt → blocked; admin must set another as default first
  • Member tries to join when Team is at seat cap → "This Team is full. Ask the admin to add a seat or remove a member."
  • Invite link leaked publicly → admin can revoke and regenerate

Reuse:

  • Community model (existing) — adds optional team_id, is_default_for_team
  • CommunityMember.role (existing) — admin/moderator/member; now scoped per-community within a Team
  • Existing community feed, posts, encouragements — no change at the per-community level

New:

  • Team, TeamMembership, TeamSubscription, TeamInvite models + migrations
  • Teams::ProvisionTeam, Teams::CreateInvite, Teams::AcceptInvite, Teams::RevokeInvite, Teams::CreateSubCommunity, Teams::ArchiveSubCommunity, Teams::PromoteTeamMember, Teams::RemoveTeamMember interactions
  • Teams::BulkInvite interaction (V1.1)
  • TeamInviteMailer with team_invite_email template
  • JoinTeamView.vue (public join landing)
  • SubCommunityBrowser.vue (the self-select screen)
  • TeamMembersTab.vue, TeamSubCommunitiesTab.vue in Team Settings
  • TeamHomeView.vue (Team dashboard with sub-community switcher)
  • TeamAccessPolicy Rails service for centralized scope checks
  • GraphQL: createTeamInvite, revokeTeamInvite, acceptTeamInvite, joinSubCommunity, leaveSubCommunity, createSubCommunity, archiveSubCommunity, setDefaultSubCommunity, promoteTeamMember, removeTeamMember, TeamType, TeamInviteType, TeamMembershipType

Success metrics (mirrors roadmap §49, adjusted for multi-community):

  • Invite link → Team join conversion: ≥80% within 7 days
  • Time from Team join to first goal event: <48 hours median
  • Median sub-communities joined per new member (multi-community Teams only): 1.5–2.5 (signals self-select is being used)
  • Bulk CSV success rate (no manual cleanup): ≥90%
  • Members who opt out of all sub-communities (decline default): <15% (sanity check that the default is helpful, not pushy)

7.3 Team Goals (Collective Progress)

Goal: A team can rally around a shared outcome — "log 1,000 collective workouts this quarter" — and see contributions roll up in real time. Because Teams own many sub-communities, collective goals can be team-wide (every member contributes regardless of which sub-community they're in) or sub-community-scoped (only members of that sub-community).

User stories:

  • As a Team Admin, I can create a team-wide collective goal that any member can contribute to.
  • As a Sub-Community Lead, I can create a goal scoped to my sub-community.
  • As a member, I can opt in to contribute my personal goal events to any collective goal I have visibility on (team-wide, or sub-communities I belong to).
  • As a team, we can see who's contributing what (lead/admin view) or anonymous aggregate (member view).
  • As a member, my personal goal data stays private; only my opted-in contributions show in team views.

Data model:

  • New CollectiveGoal model (separate from personal Goal to keep the personal-goal surface clean):

    • public_id, name, description
    • team_id (FK, required) — always tied to the Team aggregate
    • community_id (nullable FK) — when present, scoped to that sub-community; when null, team-wide
    • created_by_id (FK → User)
    • target_value (integer; e.g., 1000 for "1000 check-ins")
    • target_metric (enum: check_ins, milestones_completed, members_active_days, custom)
    • aggregation_window_start / aggregation_window_end
    • acts_as_paranoid
  • New CollectiveGoalContribution table:

    • collective_goal_id (FK)
    • user_id (FK → User; the contributor — we key off User, not CommunityMember, because team-wide goals span sub-communities)
    • personal_goal_id (nullable FK → Goal; null if contributing via team challenge rather than personal goal)
    • events_contributed (integer, denormalized counter)
    • opted_in_at, opted_out_at (timestamps)

Visibility rules (per §6 data boundary):

  • Team-wide collective goal: visible to every TeamMembership (any role).
  • Sub-community-scoped goal: visible only to members of that sub-community (plus Team Owner / Team Admin via the aggregate-counts layer; they do NOT see message-level activity inside the sub-community).

Aggregation rules:

  • Sum of contributing events across opted-in contributors, capped at target_value
  • Displayed as progress bar at top of the relevant feed:
    • Team-wide → top of Team dashboard (visible from every sub-community via shared header card)
    • Sub-community-scoped → top of that sub-community's feed only
  • Members can opt in / opt out at any time; opt-out preserves historical contributions

Member experience:

  • Team dashboard banner (for team-wide goals): "Your team is working on [goal]. Want to contribute your [matching goal]?"
  • Sub-community banner (for scoped goals): "[Sub-community] is working on [goal]. Join in?"
  • One-click opt-in modal: "Your check-ins on [Personal Goal] will count toward [Team Goal]. You can opt out anytime. Your personal goal data stays private."
  • Trust copy explicitly references the data boundary contract

Lead / Admin view (per data boundary §6):

  • Aggregate progress + per-contributor count (e.g., "Jamie: 47 contributions")
  • Team Admin viewing a sub-community-scoped goal sees: aggregate count and contributor count, but NOT message-level feed activity from that sub-community unless they're a member of it.
  • NOT visible: which personal goal Jamie linked, what Jamie's other goals are, when Jamie last checked in outside the team scope

Member view of admin data:

  • Same progress bar; per-member contributions visible only as opt-in display names if member chooses
  • "Anonymous mode" toggle: member contributes but shows as "Team member" on the breakdown

Edge cases:

  • Member leaves team mid-goal → contributions retained, attributed to "Former member" anonymized
  • Member leaves a sub-community but stays in the team → contributions to that sub-community's scoped goal frozen at point-in-time count; team-wide goal contributions continue
  • Sub-community archived → its scoped collective goals are frozen and read-only; show under "Archived collective goals" tab
  • Collective goal deleted → contribution records soft-deleted (paranoia); orphan personal goals revert to personal-only
  • Collective goal target reached before deadline → celebration moment (confetti per celebrating-with-rarity), goal stays open until deadline
  • Personal goal that opted in is deleted → contributions retained at point-in-time count; no further increments
  • Member's personal goal is private → they can still opt it in to contribute (only the count is exposed)

Reuse:

  • acts_as_paranoid
  • Celebration system from v1.18 (Celebration System PRD, completed)
  • Existing goal-event firing pattern in LogGoalEvent interaction

New:

  • CollectiveGoal + CollectiveGoalContribution models
  • Teams::CreateCollectiveGoal, Teams::OptIntoCollectiveGoal, Teams::OptOutOfCollectiveGoal interactions
  • TeamAccessPolicy check on every read/mutate (gates by team membership + optional sub-community membership)
  • CollectiveProgressBar.vue component
  • CollectiveGoalDetailView.vue (shows scope badge: "Team-wide" or "[Sub-Community Name] only")
  • GraphQL: createCollectiveGoal(teamId, communityId?), optIntoCollectiveGoal, optOutOfCollectiveGoal, CollectiveGoalType with scope enum field

Copy:

  • Banner: "Your team is climbing toward 1000 check-ins. Join in?"
  • Opt-in confirm: "Got it. Your check-ins on [Goal] will count for the team. Your goal details stay private."
  • Goal reached: "Your team did it. 1000 check-ins. That's real."

Success metrics (mirrors roadmap §53):

  • ≥40% of teams create at least one collective goal
  • Members who opt into collective goals retain at ≥1.8x baseline rate
  • Median time-to-first-contribution: <24 hours after opt-in

7.4 Team Leaderboards

Goal: Members can see their contribution context in the team — celebrated, not stack-ranked — without it triggering compulsive checking. Because Teams own multiple sub-communities, the leaderboard exposes a scope toggle: Team-wide (every member of the Team) or Sub-Community (just that community's members).

Scope semantics:

  • Team-wide leaderboard points are accrued from any team-scoped activity, regardless of which sub-community the event happened in.
  • Sub-Community leaderboard points are accrued only from activity in that sub-community.
  • A single goal event can earn points on both scopes simultaneously (no double-counting on a single board — counted once per board).
  • Default landing: Team-wide for multi-community Teams; the single sub-community's board for single-community Teams (auto-collapse).

User stories:

  • As a member, I can see how my team-scoped activity stacks up over the last week, last month, and all-time within the team.
  • As a member of multiple sub-communities, I can switch the leaderboard scope (Team-wide vs. each sub-community I belong to).
  • As a member, I can opt out of appearing on the leaderboard without losing my contributions.
  • As a member, I get one digest notification per week celebrating my position — not a real-time rank-change ping.

Points engine (per roadmap §50, refined):

EventPoints
Log a goal event (team-scoped or contributing to collective)+5
Complete a milestone (team-scoped)+15
Complete a goal (team-scoped)+50
Post in team feed+3
Encourage a teammate+2
Streak multiplier (≥7-day streak)1.2x on all of the above
Daily cap per member100

Daily cap rationale: Prevents members from grinding posts/encouragements to climb the board. 100 points = roughly 4 milestones + 1 event, which is more than any single day of real progress warrants.

Storage — new TeamLeaderboardEntry:

ruby
class TeamLeaderboardEntry < PublicRecord
  belongs_to :team
  belongs_to :community, optional: true  # null = team-wide scope
  belongs_to :user
  # scope: enum team_wide|sub_community
  # period: enum weekly|monthly|all_time
  # period_start: date (null for all_time)
  # points: integer, default 0
  # rank: integer (denormalized for cheap reads)
  # computed_at: datetime
  # index on [team_id, community_id, period, period_start, rank]
  # unique index on [team_id, community_id, user_id, period, period_start]
end

A user with team-wide + N sub-community memberships has at most (1 + N) × 3 entries per period window — denormalization is cheap because Team sizes are bounded (≤200 members in V1).

Denormalization rationale: Leaderboards are read-heavy (every member loads weekly board on app open). Computing rank on read is wasteful. We recompute on every points-earning event (debounced via Sidekiq), then read by indexed lookup.

Timeframes & windows:

  • Weekly: Mon 00:00 → Sun 23:59 in team owner's timezone (Community-level setting; defaults to America/New_York for V1, opt-in to per-team timezone in V1.1)
  • Monthly: 1st 00:00 → last-day 23:59
  • All-time (within team): since member joined

Tie-breaking: Earlier last-activity wins (encourages consistency, discourages last-minute scrambles).

Opt-out:

  • Member setting: "Show me on the team leaderboard" (default ON)
  • When off: member still earns points (counts toward collective goals, etc.) but appears as "Anonymous member #N" on the board
  • Admin cannot force a member back onto the leaderboard

Anti-addictive notification policy (per §9):

  • No real-time rank-change push notifications
  • No "X is ahead of you!" social-comparison nudges
  • One weekly "Monday Recap" notification: "Last week: you contributed [N] points across [M] check-ins. Your team hit [collective progress]."
  • All-time board is view-on-demand, no notifications

Computation flow:

  1. Member logs goal event → LogGoalEvent interaction runs
  2. Inside transaction: if event is team-scoped → Teams::AwardPoints interaction resolves the event's Team + originating sub-community, then enqueues RecomputeLeaderboardJob(team_id, community_id, user_id, period) for each affected scope (team-wide and the originating sub-community)
  3. Job:
    • Inserts/updates TeamLeaderboardEntry for user × scope × period
    • Recomputes ranks for affected scope+period via SQL window function (ROW_NUMBER())
    • Caches result in Redis (5-min TTL) keyed by leaderboard:{team_id}:{community_id|"team"}:{period}:{period_start}

Edge cases:

  • Member rejoins after leaving → starts at 0 in new period; historical "Former member" entries stay attributed
  • Team has 2 members → leaderboard still shows but with "Invite more members to make it interesting" empty state nudge
  • All members opt out → leaderboard hidden, replaced with "Your team prefers private progress. Switch on in your settings."
  • Member is removed mid-week → their entry archived as "Former member" with their final point total
  • Member leaves a sub-community but stays in team → drops off that sub-community's board going forward, stays on team-wide; historical entries archived as "Former member of [Sub-Community]"
  • Single-community Team scope toggle: hidden; default to team-wide board only (no UX tax)

Reuse:

  • Existing CommunityMember.points field (per roadmap §50) — repurpose as cached sub-community-scoped all-time points for that community
  • Existing community feed for "Monday Recap" delivery option (in-app)
  • Existing Sidekiq job patterns

New:

  • TeamLeaderboardEntry model
  • Teams::AwardPoints, Teams::RecomputeLeaderboard interactions
  • RecomputeLeaderboardJob, WeeklyRecapJob (Crono-scheduled, Mondays 9am team TZ)
  • TeamLeaderboardView.vue with scope toggle (Team-wide / each sub-community user belongs to) + weekly/monthly/all-time toggle
  • LeaderboardEntryRow.vue (rank, avatar, display name, points, delta arrow)
  • GraphQL: teamLeaderboard(teamId, communityId: null, period), setLeaderboardVisibility

Copy:

  • Weekly recap: "Last week you logged 18 check-ins on your team goals. That's #3 on the team — and #1 in consistency."
  • Empty state (2 members): "Invite a few more teammates and this gets interesting."
  • All members opted out: "Your team's keeping the board private. Want to opt in?"

Success metrics (mirrors roadmap §50, anti-engagement tuned):

  • Leaderboard viewers log events ≥30% more (validated against control)
  • Weekly board check rate: ≥70% of active team members
  • Opt-out rate: ≤10% (sanity check that we're not nudging too hard)
  • Anti-metric: push notification opt-outs from team members <5%/quarter (if we're notifying too much, this rises)

8. V1.1+ Shape-Only Sections

For each: problem statement, sketch, open questions, sequencing.

8.1 Custom Badges & Challenges

Problem: Teams want to celebrate their own milestones (e.g., "First Aid Cert," "Q1 Sales Goal"), not just generic platform badges. Sketch: Admin defines CustomBadge (name, emoji, description, trigger). Trigger = manual award OR automatic criteria (e.g., "complete 10 check-ins in a 30-day challenge"). Extends CommunityChallenge from Phase 4. Open questions: Should custom badges count for collective goals? Should they have rarity tiers like platform badges? Image upload (vs. emoji-only) for V1.1 — moderation cost? Dependencies: Phase 4 CommunityChallenge (shipped). V1 leaderboards (points engine). Sequence: V1.1, sprint 6–7

8.2 Team Admin Dashboard & Analytics

Problem: Admins need a single screen that answers "How is my team doing?" without bouncing between Members, Goals, and Leaderboard tabs. Sketch: Community-scoped analytics modeled after AdminStatsService from the standalone admin app. Cards: active rate (7d/30d), goal completion rate, average team-scoped streak, mood-trend opt-in aggregate (anonymized N≥5), member-health flags (members inactive 7+ days). CSV export per the data boundary contract (§6). Open questions: Is mood-trend aggregate (anonymized) acceptable under data boundary? Strict reading is "no." Recommendation: defer; admins do not get any mood signal in V1.1. Dependencies: V1 leaderboards + collective goals (data sources). Existing AdminStatsService pattern. Out of scope for V1: no admin analytics screen at all in V1 — admins read team health off the Members list and Leaderboard tabs individually. Sequence: V1.1, sprint 5–6

8.3 Accountability Pair Auto-Matching

Problem: Solo accountability is hard; assigned partners stick. Sketch: Algorithm pairs members based on shared goal categories, similar activity level (last 14d events), and time-zone proximity. Weekly pair summary email. Extends existing UserAlly with accountability_partner flag. Open questions: Forced pairing vs. opt-in (recommend opt-in only)? What if an even number of members but odd shared-category clusters? Dependencies: Existing UserAlly model (Accountability Partners PRD, shipped). Out of scope for V1: no auto-matching in V1 — members can only form accountability pairs manually via the existing Accountability Partners flow, same as outside a Team. Sequence: V1.1, sprint 7

8.4 Manager View

Problem: Coaches with 15+ clients need a grid view of progress, not a long scroll of profiles. Sketch: Table of all team members: active goals (count), team-goal contributions, streak (team-scoped), last activity (day), per-data-boundary §6. Sortable, filterable. No personal-goal titles, no mood data. Open questions: Pagination cutoff (50 rows)? Bulk-message-team button (only via in-app feed post, no DM)? Dependencies: Data boundary contract enforced everywhere. Out of scope for V1: no grid/table view in V1 — admins browse members one profile at a time via the standard Members list. Sequence: V1.1, sprint 8

8.5 Priority Support

Problem: Teams need a clear SLA promise; today our support is a single inbox. Sketch: In-app support widget routes Teams members to a priority queue (target <4h response Mon–Fri; <24h for free). Onboarding concierge for ≥10-seat teams (Vicki / Multica triage support). Open questions: Who staffs the priority queue? Build vs. buy (Intercom-style tool)? Dependencies: Support tooling decision (separate ADR). Sequence: V1.1, sprint 8

8.6 SSO / SAML (deferred to enterprise tier)

Problem: Corporate IT mandates SAML for any tool with >25 seats at most mid-size companies. Sketch: SAML 2.0 + OIDC. SsoConfiguration on Community. Auto-provision + auto-attach. Enforce SSO-only login for configured teams. Open questions: How does SSO interact with Clerk (which is our IdP for individuals)? Clerk Enterprise add-on? Dependencies: First enterprise-tier deal (estimate 18–24 months post-Teams launch). Sequence: Gated by first 3-seat enterprise deal. Not on V1.x roadmap.

8.7 Slack / Microsoft Teams Integration

Problem: Wellness teams and coaching groups already live in Slack/Teams; goal completions should flow to where the team already is. Sketch: Webhook-based outbound. Events: goal completed, milestone reached, streak milestone (7/30/100), weekly leaderboard digest, challenge started/completed. Optional slash command /objectuve check-in "Ran 5km" for direct logging. Open questions: App marketplace listing (Slack App Directory)? Volume limit per channel/day (anti-spam)? Should leaderboard digest be opt-in per member or per channel? Dependencies: Outbound webhook infra (small lift). Bot/slash-command requires Slack app review (~2 weeks). Sequence: V1.2, sprint 9–10

8.8 Per-Team Brand Customization

Problem: Coaches with established brands want their team to feel like their brand, not Objectuve. Sketch: Light-touch only. Team can set: accent color (HSL picker, contrast-validated), team logo (replaces avatar in team chrome), optional welcome message. Does NOT override platform UI, fonts, or critical interactions. Open questions: White-labeling on the email side? (Recommend: footer-only co-brand for V1.x, full white-label for enterprise tier.) Dependencies: Design system token override mechanism (small lift). Sequence: V1.2, sprint 10

8.9 Bulk CSV Invite

Problem: Coaching-ICP leads (5–20 seats) invite one at a time via link or email and that's fine — but the corporate-wellness anti-persona (§3, not the V1 lead ICP but present in the V1.1 target) onboards cohorts of 20–100+ at once, and re-typing that many emails is a real activation blocker. Sketch: Extra panel inside Team Settings → Invites (N7d): drop a CSV with email, team_role, communities columns; client-side preview shows valid/malformed row counts before send; "Assign all to ▾" bulk-applies a sub-community if the CSV omits it; a single "Send invites" fires the existing per-member invite pipeline (N23) once per valid row. Open questions: Malformed rows — silently skip and report, or block the whole send until fixed? Row cap per import (protects Sidekiq queue + Stripe seat-limit checks)? Do bulk-invited members get the standard N23 invite email, or an "your organization added you" variant? Dependencies: N7d (Team Settings · Invites tab, V1). N23 (Team invite email, V1). Out of scope for V1: no CSV import in V1 — V1 invite is link generation (N8) or one-at-a-time email (N7d) only, per Open Question #3 (§12). Sequence: V1.1, sprint 5 (same decision that deferred it from V1 — see §12 Open Question #3).

Problem: In a multi-sub-community Team, only the Team admin can generate invite links (N8) in V1. A sub-community Lead (e.g., "Morning Movers" lead) running their own room has no way to invite people directly into just that room without asking the admin. Sketch: Same shape as N8 (invite-member modal — link + email), but generated by a sub-community Lead: the sub-community checkbox is pre-checked and locked to the Lead's own room (no "any sub-community" team-wide option, since a Lead isn't scoped to see the whole Team). Open questions: Can a Lead revoke/rotate their own link independently of the admin's Team-wide link? Does the Lead-generated invite show the full Team privacy panel (N7e) or a sub-community-scoped variant of it? Dependencies: N8 (Invite-member modal, V1). Sub-community Lead role (already modeled — assigned via N9's Lead picker). Out of scope for V1: invites are admin-only in V1 (N8, N7d) — Leads have no independent invite capability until V1.1. Sequence: V1.1.


9. Anti-Social-App Constraints Applied to Teams

These are non-negotiable design rules. Every Teams feature is reviewed against this list at design and code-review gates.

  1. No real-time rank-change push notifications. Members see rank only when they open the app. Period.
  2. No "X is ahead of you!" social-comparison copy. Leaderboard celebrates contribution, not relative position loss.
  3. No admin-configured "always-on Slack mirror" that posts every event. Slack integration (V1.2) defaults to digest mode.
  4. Max one team-scoped push notification per member per day. All other signals are in-app (visible on open) or weekly digest.
  5. No streak-anxiety mechanics for team goals. Collective goals have a target + deadline, not a "team streak" that can be broken.
  6. No daily-active-user gamification incentive for admins. The admin dashboard does NOT optimize for "make members check in every day." It surfaces health signals only when there's a 7-day inactivity flag.
  7. Same ~10-min/day session target as the individual app. Team features are check-in oriented, not browse-oriented. No infinite scroll on team feed.
  8. Notification opt-out is one tap from any team notification. No dark-pattern unsubscribe flows.
  9. Leaderboard opt-out preserves contributions. Members are never penalized (functionally or socially) for not appearing on the board.
  10. Coach / external coach role is read-only and clearly labeled. No silent observation; members see "Coach can see your team activity" in a privacy panel.

10. Success Metrics

North Star (12 months post-V1 GA)

MetricTarget
Paying teams10–30
MRR$2,000–$8,000
Avg team size6–10 seats
Monthly logo churn<5%
Annual revenue retention≥95%

V1 MVP activation (per feature)

MetricTargetSource
Trial → paid conversion≥40% within 14 daysBilling (§7.1)
Invite link → join≥80% within 7 daysInvites (§7.2)
Time to first goal event after join<48 hours medianInvites + onboarding
% teams with ≥1 collective goal≥40% within 30 days of paidCollective goals (§7.3)
Leaderboard view rate≥70% of active members weeklyLeaderboards (§7.4)
Leaderboard opt-out rate≤10%Leaderboards (§7.4)

Engagement (vs. free community baseline)

MetricTargetNotes
Team member DAU/MAU≥1.5x baselinePer north-star.md
Team member 30-day retention≥1.8x baseline
Team-scoped check-ins per member per week≥4 medianAnti-grind: capped at 100 pts/day

Multi-sub-community adoption (validates the architectural bet)

MetricTargetNotes
% Teams with ≥2 sub-communities at 90 days≥25% of Teams ≥10 seatsIf <10%, the multi-community surface is over-built for the audience
Median sub-communities per Team (size ≥10)2–3<2 → architecture not paying its UX cost
% members of multi-community Teams in ≥2 sub-communities≥35%Self-select working
Sub-Community Browser → join rate (first session)≥60% join ≥1 non-defaultValidates browser UX

Trust (data boundary §6)

MetricTarget
Admin tickets re: data visibility/privacy<2% of admins/quarter
Member tickets re: "what can my admin see?"<5% of members/quarter
Privacy contract panel views per member≥1 within first 7 days (good — shows we surface it)

Anti-metrics (we want these LOW)

MetricThreshold
Team-related push-notification opt-outs<5%/quarter
Members reporting "feel surveilled" in NPS commentsZero tolerance — any signal triggers review
Session length increase past 12-min P50 for team membersStop and rethink

11. Rollout Strategy

Closed beta (60 days)

Cohort: 5 hand-picked coaching groups from Josh's network. Total ~30 seats. At least one cohort must be a 12+ seat group that wants ≥2 sub-communities (validates the self-select UX and the multi-community data boundary). Pricing: Free (no card collection). Feature flag: teams_enabled set per-team (admin only). Goals:

  • Validate billing flow end-to-end on test mode → live mode
  • Validate data boundary contract (no admin says "I wish I could see X" where X is forbidden)
  • Validate Sub-Community Browser → first-join flow on the multi-community beta cohort
  • Validate leaderboard scope toggle (no confusion about Team-wide vs. Sub-Community)
  • Validate leaderboard mechanics (no anti-metric violations)
  • Generate 3+ testimonial quotes for pricing page

Exit criteria: All five teams complete a full 14-day trial cycle (simulated billing); zero data-boundary incidents; ≥3 admins convert to paid in week 9.

Open beta (60 days)

Cohort: Existing community admins with ≥5 members who opt in via in-app banner. Pricing: 50% discount ($3.50/user/mo) for the open-beta cohort, locked in for 6 months post-GA. Feature flag: teams_enabled rolled to all communities with admin opt-in toggle. Goals:

  • Stress-test billing webhooks at higher volume
  • Validate invite flow on mixed-account-state (existing user, new user, Apple Hide-My-Email)
  • Identify support-load signals (priority queue capacity planning)

Exit criteria: 20+ communities trying Teams; ≥30% conversion to paid at end of beta; no Sev-1 billing incidents.

GA

Timing: Sprint 5 of Phase 7 execution Triggers:

  • Pricing page live (Marketing landing + in-app upgrade flow)
  • teams_enabled flag → 100% rollout, then retired
  • Press: Objectuve blog post, Josh's LinkedIn, Indie Hackers post Comms:
  • Email to all community admins (~Lifecycle email channel from v1.x)
  • In-app banner for 14 days (admin role only)
  • One-time Monday Recap insertion: "Now available: Teams."

Feature flag plumbing

Per the CLAUDE.md gotcha: any new flag must be registered in both PostHog project 368400 AND ionic_frontend/src/lib/featureFlags.ts. The PostHog CI drift check (posthog-flag-drift in .github/workflows/ci.yml) will catch desync.

Flags introduced:

  • teams_enabled — top-level Teams feature visibility (open during beta, retired at GA)
  • teams_billing_v1 — kill switch for Stripe webhook handling
  • teams_collective_goals — kill switch for §7.3
  • teams_leaderboards — kill switch for §7.4
  • teams_bulk_invite (V1.1) — CSV invite flow

All flags retired within 1 release cycle of GA per the flag-lifecycle policy.


12. Open Questions and Decisions for Owner

#QuestionOptionsRecommendationDecision ownerDue
1Final Teams price$5 / $7 / $9 per seat/mo$7/mo, $70/yr, 14-day trialJoshBefore pricing-page copy (Phase 7 Sprint 1)
2Data model: extend Community 1:1 vs. separate Team aggregate that has_many CommunitiesA: extend 1:1 / C: separate Team + many Communities / D: nested TeamsC: separate Team aggregate, Team has_many :communities (drives multi-sub-community vision; see §5)JoshBefore first migration (Phase 7 Sprint 1)
3Bulk CSV invite — V1 or V1.1?V1 / V1.1V1.1 (coaching ICP doesn't need it; corporate wellness ICP does, but isn't lead)JoshSprint 2
4Mood-trend aggregate visible to admins (anonymized, N≥5)?Yes / No / Per-team toggleNo (strict reading of data boundary; revisit at SOC 2 phase)JoshBefore V1.1 admin dashboard sprint
5Annual discount magnitude10% / 17% / 20%17% ($70 vs. $84 — clean round number)JoshSame as #1
6Trial requires card?Yes / NoNo (lower friction, better trial→paid conversion in B2C-leaning ICPs)JoshSame as #1
7Max team size at launch50 / 100 / 200 / unlimited200 (covers all V1 ICPs; enterprise tier later for larger)JoshSprint 1
8Timezone handling for weekly leaderboardsTeam-level / member-level / hardcoded UTCTeam-level, default America/New_YorkJoshSprint 3
9Refund policyNone / 7-day / 30-day7-day, handled manually via Stripe dashboardJoshBefore GA
10Beta participant compensationFree during beta / discounted in perpetuity / lifetime freeFree during beta + 50% discount locked for 6 months post-GA (open beta)JoshBefore open beta
11Default sub-community auto-join behaviorAuto-join default community / require explicit pick from browser even for defaultAuto-join the default community on invite acceptance; surface browser only when ≥1 non-default sub-community existsJoshSprint 3
12Sub-community archive grace periodImmediate / 7 days / 30 days30 days — soft-archive (read-only), then hard-archive (paranoia-deleted); members get one notification on archiveJoshSprint 3
13External Coach seat-billable?Counts as seat / free addon (max 1 per team) / free addon (max 2 per 10 seats)Free addon, max 1 per team in V1.2; seat-billable in V2 if observed coaches > 1 per team becomes commonJoshBefore V1.2 External Coach build
14Cap on sub-communities per teamNone / 5 / 10 / scale with seat count10 sub-communities per Team in V1; raise on request after observing usageJoshSprint 3
15Can members create sub-communities (self-serve) or admins only?Members can / admins only / admins + designated leadsAdmins only in V1; designated-leads (delegate) in V1.1JoshSprint 3
16Sub-community discovery: "open to all team members" vs. invite-only-within-teamAll members can browse and join / Lead controls join (request-to-join) / Mix per sub-communityMix per sub-community — toggle on sub-community creation (default: open within team)JoshSprint 3

13. Out of Scope (Explicit Non-Goals for V1)

These are not "no, never" — they're "not in V1, on purpose."

  • Multi-team membership UX polish. A user can join multiple teams in V1; the navigation just stays simple. Polish (team switcher, cross-team notifications dedupe) deferred.
  • Cross-team leaderboards / federated views. No "compare our team to other teams" feature. This would shift the product toward social comparison, which we reject.
  • Public marketplace of team templates. Future Phase 6 work, not Teams.
  • Per-team currency / internationalization. USD only at launch; EUR/GBP in V1.2 if EU traction emerges.
  • Enterprise contracts, custom MSAs, procurement workflow. Self-serve only.
  • Annual reporting / year-in-review for teams. Future polish.
  • HIPAA, SOC 2, ISO 27001 certification. Post-Series-A (~3yr).
  • K-12 school flows (FERPA). College / bootcamp / adult cohorts only.
  • Per-team custom integrations beyond Slack/MS Teams. No Zapier, Salesforce, HRIS at launch.
  • Refunds via in-app UI. Stripe dashboard only.
  • Team-to-team transfers / mergers. Not a real use case yet.

14. Glossary, References, Appendix

Glossary

TermMeaning
TeamA Team aggregate record with an active TeamSubscription. Owns one or more Community records (sub-communities).
Team OwnerUser referenced by Team.billing_owner_id; manages billing; carries Team Admin role automatically via TeamMembership.role == "owner"
Team AdminTeamMembership.role == "admin"; full operational access across the Team (invite, remove, create sub-communities, see aggregate analytics); no billing
Team MemberTeamMembership.role == "member"; participates in goals, challenges, leaderboards within sub-communities they've joined
External CoachTeamMembership.role == "external_coach"; V1.2 — read-only observer; free addon (per Open Question #13)
TeamMembershipThe join record between a User and a Team. Carries the Team-level role. One per (user, team) pair.
Sub-CommunityA Community owned by a Team (Community.team_id set). Members self-select into one or more sub-communities.
Default CommunityThe single sub-community auto-provisioned when a Team is created (Community.is_default_for_team == true). Cannot be archived while the Team exists. Acts as the "lobby" for small Teams.
Sub-Community LeadCommunityMember.role == "admin" on a non-default sub-community. Creates sub-community-scoped goals, runs challenges, manages that community's feed. NOT a Team-wide admin.
Sub-Community ModeratorCommunityMember.role == "moderator"; can post, encourage, run challenges within that sub-community
Collective GoalA CollectiveGoal record tied to a Team (always) and optionally scoped to a single sub-community. Multiple members contribute.
Team SubscriptionTeamSubscription record; links Team ↔ Plan ↔ Stripe
SeatOne billable user-slot; equals one active TeamMembership (regardless of how many sub-communities that user belongs to)
Team ScopeThe set of goals, events, badges, and points attributed to activity within this Team or any of its sub-communities
Sub-Community ScopeA narrower slice of Team Scope, attributed to a specific sub-community
Personal ScopeEverything outside Team Scope — protected from admin visibility per §6
TeamAccessPolicyService object centralizing all "can user X read/mutate Y in Team T" checks (§5)

References

  • docs/product/roadmap.md §Phase 7, items #47, #49, #50, #51, #52, #53, #54, #55, #56, #57, #58
  • docs/product/phase-7-monetization-teams-prd.md — predecessor (Supporter-tier scope retained there)
  • docs/product/pricing-philosophy.md — pricing rationale (Teams section to be updated per §4)
  • docs/product/north-star.md — 12mo/3yr targets
  • MISSION.md — anti-social, no-data-selling commitments (constraint source for §6, §9)
  • docs/brand/brand.md — tone, visual constraints
  • rails_api/app/models/community.rb, community_member.rb, plan.rb, payment_record.rb — existing schema reused
  • rails_api/app/services/stripe_service.rb, rails_api/app/interactions/billing/ — existing billing infra
  • ionic_frontend/src/lib/featureFlags.ts — flag registration target
  • docs/development/feature-flags.md — flag lifecycle policy
  • docs/architecture/authentication.md — Clerk integration patterns (Teams uses same)

Appendix A — Pricing comparison

ToolPrice/user/moAnnualPositioning anchor
Slack (Pro)$7.25$87The default "we know what this costs" benchmark
Asana (Starter)$10.99$131.88PM tool — bracket above Objectuve
Strava Premium (per user equivalent)$11.99 (individual)$79.99Closest fitness-accountability frame
Habitica Group Plan$9 (group of 4)Direct adjacent product
StickkVariable (commitment-based)Adjacent in commitment-device space
Objectuve Teams (recommended)$7$70Below Slack, above hobby tools

Appendix B — Competitive feature matrix

FeatureObjectuve TeamsSlackStrava ClubsAsana Goals
Personal goal tracking✅ Core✅ Activities
Team accountability✅ CorePartial
Coach role✅ V1.2
Custom badges✅ V1.1
Anti-social-app design✅ Principle
Member privacy from admins✅ §6 contractN/APartial❌ (work tool)
Slack integration✅ V1.2N/A
Pricing (per seat/mo)$7$7.25$11.99 (individual)$10.99

Appendix C — V1 sprint sequencing (high-level, 10 sprints)

SprintFocusExit signal
1Pricing decisions, data model migration, TeamSubscription + Plan rowsMigration on staging; Plans seeded
2Billing flow (§7.1), Clerk Billing integration, webhook plumbingTest-mode subscription completes end-to-end
3Private team invites (§7.2), join flow, Members tabClosed beta cohort can be invited
4Team Leaderboards (§7.4), points engine, weekly recapLeaderboard reads/writes pass load test
5Collective goals (§7.3), contribution opt-in flow, progress UIClosed beta cohort opts in
6Closed beta cohort onboarded; feedback intake; bug fixesAll 5 teams active for 14 days
7V1.1 prep: custom badges, team admin dashboard analyticsOpen beta cohort onboarded
8V1.1: accountability pair auto-matching, manager viewManager view shipped
9V1.1: priority support routing; rollout polishSupport queue live
10GA: pricing page, lifecycle email, press, flag retirement$7 price live; teams_enabled flag retired

End of PRD. Ready for engineering kickoff after the 10 open decisions in §12 are resolved.

Loading…