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):
- Billing & Subscriptions — Clerk Billing on top of existing Stripe infrastructure; seat-based at the Team level; Team Owner pays once for all sub-communities
- Team + Sub-Community Architecture — new
Teamaggregate,TeamMembership(the seat),has_many :communities; every Team auto-provisions a default Community; admins can create additional sub-communities - 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)
- 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
- 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
Teamaggregate 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:
| Horizon | Teams | Avg seats | MRR |
|---|---|---|---|
| 12mo (Apr 2027) | 10–30 | 6–10 | $2K–$8K |
| 3yr (Apr 2029) | 200–800 | 8–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
| Persona | Role | Key needs | What they don't need |
|---|---|---|---|
| Team Owner | Pays the bill, sets up the Team and its sub-communities | Billing transparency, seat management, transferable ownership, ability to provision/archive sub-communities | Day-to-day moderation tooling |
| Team Admin | Delegated admin (e.g., assistant coach, HR partner) | Invite/remove members across the Team, create sub-communities, see team-wide activity | Billing access |
| Sub-Community Lead | Admin/moderator of one sub-community within a Team (e.g., "Running Crew lead") | Manage their sub-community feed, challenges, and collective goals | Cross-community visibility outside their sub-community |
| Team Member | The accountability subject | Browse + self-select into sub-communities, contribute to goals, see leaderboards scoped where they participate | Admin tooling, other members' private data, sub-communities they didn't join |
| External Coach | Non-billable observer (e.g., a coach's coach) | Read-only view scoped to specific sub-communities | Personal 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
| Source | Teams price | Annual |
|---|---|---|
docs/product/roadmap.md (Phase 7 header) | $5/user/month | $50/user/year |
docs/product/pricing-philosophy.md | $9/user/month | not 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 MRR | 400 | 286 | 222 |
| Seats for $8K MRR | 1,600 | 1,143 | 889 |
| Avg team of 8 → MRR contribution | $40 | $56 | $72 |
| 30 teams × 8 seats avg | $1,200/mo | $1,680/mo | $2,160/mo |
| Competitive frame | Below 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 yes | Easy yes | "Worth it?" friction |
| ICP fit — corporate wellness (budget-led) | "Why so cheap?" trust hit | Credible | Credible |
| Margin headroom for Stripe (~3% + $0.30 / seat / mo) | Tight | Comfortable | Comfortable |
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_sessionsetspayment_method_collection: 'if_required'— Stripe collects no card at checkout, matching this decision. Since no card exists, "card required to convert" is enforced viasubscription_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,startTeamBillingPortalmutation), the invoice auto-charges and the subscription converts toactive. If not, the invoice stays unpaid and the team falls into the existingpast_due→grace→canceledlifecycle (§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.mdline ~989 ($5 → $7) - Update
docs/product/pricing-philosophy.md(Teams section) - Update
docs/product/revenue-projections.mdif 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)
| Dimension | Option 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 cost | 1 migration, ~6 cols | 2 new tables (Team, TeamMembership), team_id FK on Community, ~12 cols total | 3 new tables, recursive FK on Team |
| Billing surface | Per-Community subscriptions | One subscription per Team; seat = TeamMembership | One per leaf Team; complex aggregation |
| GraphQL surface | Fields on CommunityType | New TeamType, TeamMembershipType, TeamSubscriptionType; CommunityType gains optional team field | + TeamHierarchyType |
| Permissions complexity | Single role on Community | Two-level: Team role + per-Community CommunityMember role | Three-level; high test burden |
| Data-boundary enforcement | Single scope to check | Two scopes (Team vs. Community); §6 contract must cover both | Three scopes |
| Risk of leaking sub-community A activity to non-member of A | N/A | High if not carefully gated — must be in §6 contract | Higher |
| Codebase reality | Reuses Community as aggregate | Community 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 optionalteam_idFK +is_default_for_teamboolean. - Two-level permissions (Team role + per-Community role) are simpler than they sound: most members will have
Team role = member+Community role = memberfor 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
TeamAccessPolicyservice object that centralizes the rules.
Auto-provisioning behavior (critical for small-team UX)
When a Team is created:
Teamrecord created withslug,name,billing_owner_id- One Community auto-created with
is_default_for_team: true, name defaulted to the Team's name,team_idset,private: true - Team Owner becomes admin of both Team and default Community
- 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
# 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
endNotes:
TeamMembershipis the seat. Billing'sseat_countalways matches the count of active TeamMemberships (excludingexternal_coachrole, which is configurable as billable / non-billable per Team).CommunityMember(existing) remains the per-Community membership; a user has 1TeamMembershipplus 0..NCommunityMemberrows within Communities of that Team.Community.team_idis nullable so existing public/free Communities are unaffected by migration.- A
TeamAccessPolicyservice 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.
| Role | Sees |
|---|---|
| Team Owner / Team Admin | Team-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 Coach | Read-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 columns | Disallowed columns |
|---|---|
| Member display name, joined-at, last-active date (day) | Real name, email, phone |
| Team-goal contribution counts, challenge results | Personal goal titles, descriptions |
| Team leaderboard points (period-scoped) | Lifetime XP, lifetime streaks |
| Custom badges earned within team | All 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):
- User visits /teams/new (from pricing page CTA or in-app upgrade banner)
- Plan selector shows Monthly ($7/seat) and Annual ($70/seat, "Save ~17%") with seat quantity stepper (default 5, min 1, max 200 for V1)
- Team name input (also becomes default Community name; admin can rename later)
- "Start 14-day free trial" CTA → Clerk Billing checkout (Stripe-backed)
- On
checkout.session.completedwebhook →Teams::ProvisionTeaminteraction runs:- Creates
Teamrecord +TeamSubscription(statustrialing,trial_ends_at = now + 14.days) - Creates
TeamMembershipfor the Team Owner (roleowner) - Auto-provisions default Community:
Community.create!(team: team, is_default_for_team: true, private: true, name: team.name, creator: owner)+CommunityMemberfor owner with roleadmin - Triggers
TeamUpgradedJob(welcome email + in-app celebration)
- Creates
- Owner lands on Team Settings → Invite Members tab with empty state CTA
Flow — convert existing private community into a Team:
- Community admin visits Community Settings → Upgrade to Teams
- Same plan selector flow as above
- On webhook:
Teams::ProvisionTeamcreates Team + Subscription; existing Community is attached viacommunity.update!(team: team, is_default_for_team: true); existing members getTeamMembershiprows (rolemember, original CommunityMember role preserved) - 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:
invoice.payment_failedwebhook →TeamSubscription.status = "past_due"- Email Team Owner immediately + day 3 + day 6
- Team continues to work normally (full features) for 7 days
- 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) - Day 14:
Teams::PaymentFailedJob's daily sweep transitions the subscription out of grace:- Stripe subscription is canceled (
StripeService.cancel_subscription) TeamSubscription.status = "canceled"Teams::TeamCanceledJobfires: logs the transition and emits ateam_canceledPostHog 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 —
TeamAccessPolicygrants 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
- Stripe subscription is canceled (
Webhook events handled:
| Event | Action |
|---|---|
checkout.session.completed | Provision subscription |
customer.subscription.updated | Sync seat count, status, period end |
customer.subscription.deleted | Logged 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_end | Email owner 3 days before trial ends |
invoice.paid | Mark active, clear past_due flags |
invoice.payment_failed | Mark past_due, start grace timer |
customer.subscription.pending_update_applied | Confirm 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 theinvoice.paidhandler 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.paidrow above is now implemented.Webhooks::StripeControllerresolves a teaminvoice.paidevent viaTeamSubscription.find_by(stripe_subscription_id:)and routes it toTeams::ProcessInvoicePaid, which transitionstrialing/past_due/grace→active, refreshescurrent_period_endfrom Stripe on every event including renewals, and fires atrial_convertedPostHog event exactly once per conversion.canceledis deliberately not a recovery source state — reactivation after cancellation requires a fresh checkout, the same pattern as re-subscribing inTeams::ProvisionTeamSubscription. Team events never reachBilling::ProcessStripeWebhook(that dispatcher stays Supporter-only). See PR #1515.Status note (2026-07-15, OBJ-1412): the
invoice.payment_failedrow above is also now webhook-driven, not clock-inferred —Teams::ProcessInvoicePaymentFailedmovestrialing/active→past_dueand stampscurrent_period_end = Time.currentso the 7+7-day grace/cancel timers start from the moment of failure. This replaces the OBJ-1411 note's earlier claim that a dailyTeams::TrialExpiredJobsweep could flip a genuinely-paid sub topast_due: that job is now a safety-net alarm only (logs + pages Sentry if a subscription is stilltrialingpasttrial_ends_at, meaning the webhook is late or missing) and no longer writespast_dueitself — see the runbook's "what T3 changed" section for the full before/after. The table'scustomer.subscription.deleted/ auto-cancel path also changed:Teams::PaymentFailedJob#transition_to_cancelednow callsStripeService.cancel_subscriptionbefore marking the local rowcanceled, and skips the local cancellation entirely (fails closed, retries next day) if Stripe still reports the subscriptionactive— closing the "charged forever after cancellation" gap the original webhook table didn't have to account for. All routing above happens inWebhooks::StripeController#route_event, which resolves each event againstTeamSubscriptionfirst and only falls through toBilling::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_readonlybanner (TrialStatusBanner.vue) told users the team was read-only, but every write CTA it described stayed fully functional — no interaction anywhere gated onsubscription.status. A sharedTeams::Concerns::RequiresWritableSubscriptionguard now blocks exactly three interactions with ateam_read_onlyGraphQL error whilestatus == 'grace':create_team_invite("no new invites"),create_sub_community("no new sub-communities"), andcreate_collective_goal("no challenge creation"). Matching locked-CTA states (LockedWriteButton.vue) ship onTeamMembersTab,TeamInvitesTab, andTeamSubCommunitiesTab. "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_countincremented via Stripe API → prorated charge on next invoice - Remove seat (member leaves):
seat_countdecremented 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:
Planmodel — add rows forteams_monthlyandteams_annualPaymentRecordmodel — extend to record team subscription transactions (add nullableteam_subscription_id)StripeService— extendcreate_checkout_sessionto acceptquantityparameterBilling::ProcessStripeWebhook— extend dispatcher
New:
TeamSubscriptionmodel (schema in §5)Teams::ProvisionTeamSubscriptioninteractionTeams::AdjustTeamSeatsinteractionTeams::TransferTeamBillingOwnershipinteractionTeams::CancelTeamSubscriptioninteractionTeamUpgradedJob,TeamDowngradedJob,Teams::TrialEndingJob
GraphQL surface:
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:
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
endInvite flows:
Team link invite (default, for coaches DMing clients):
- Admin → Team Settings → Invite → Copy Team Link
- Link format:
https://app.objectuve.com/join-team/{code} - Recipient clicks → if signed in, accepts → lands on Sub-Community Browser (see below); if signed out, signs up via Clerk first
- 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):
- Sub-Community Lead → Community Settings → Invite to this Community
- Generates a
TeamInvitewithpreselected_community_idscontaining just that sub-community - Recipient still joins the Team, but the sub-community browser pre-checks the named sub-community
Email invite:
- Admin enters one or more emails (textarea, comma/newline separated); optionally pre-selects sub-communities for these invitees
- Each gets a
TeamInvitewithemailandpreselected_community_idsset TeamInviteMailersends email (subject: "[Owner name] invited you to join [Team name] on Objectuve")- Recipient clicks email link → sub-community browser (with pre-selections) → accepts
- Status:
acceptedon join,expiredafter 14 days,revokedon admin action
Bulk CSV (V1.1 — corporate wellness):
- Admin uploads CSV with
emailcolumn (optional:team_role,display_name,communitiessemicolon-separated slugs) - Preview shows count + any malformed rows + an "all" toggle to assign every invitee to a specific sub-community
- Bulk-create
TeamInviterecords; emails queued via Sidekiq batch - 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 noCommunityMemberrows; 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:
Communitymodel (existing) — adds optionalteam_id,is_default_for_teamCommunityMember.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,TeamInvitemodels + migrationsTeams::ProvisionTeam,Teams::CreateInvite,Teams::AcceptInvite,Teams::RevokeInvite,Teams::CreateSubCommunity,Teams::ArchiveSubCommunity,Teams::PromoteTeamMember,Teams::RemoveTeamMemberinteractionsTeams::BulkInviteinteraction (V1.1)TeamInviteMailerwithteam_invite_emailtemplateJoinTeamView.vue(public join landing)SubCommunityBrowser.vue(the self-select screen)TeamMembersTab.vue,TeamSubCommunitiesTab.vuein Team SettingsTeamHomeView.vue(Team dashboard with sub-community switcher)TeamAccessPolicyRails 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
CollectiveGoalmodel (separate from personalGoalto keep the personal-goal surface clean):public_id,name,descriptionteam_id(FK, required) — always tied to the Team aggregatecommunity_id(nullable FK) — when present, scoped to that sub-community; when null, team-widecreated_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_endacts_as_paranoid
New
CollectiveGoalContributiontable: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 SystemPRD, completed) - Existing goal-event firing pattern in
LogGoalEventinteraction
New:
CollectiveGoal+CollectiveGoalContributionmodelsTeams::CreateCollectiveGoal,Teams::OptIntoCollectiveGoal,Teams::OptOutOfCollectiveGoalinteractionsTeamAccessPolicycheck on every read/mutate (gates by team membership + optional sub-community membership)CollectiveProgressBar.vuecomponentCollectiveGoalDetailView.vue(shows scope badge: "Team-wide" or "[Sub-Community Name] only")- GraphQL:
createCollectiveGoal(teamId, communityId?),optIntoCollectiveGoal,optOutOfCollectiveGoal,CollectiveGoalTypewithscopeenum 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):
| Event | Points |
|---|---|
| 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 member | 100 |
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:
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]
endA 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:
- Member logs goal event →
LogGoalEventinteraction runs - Inside transaction: if event is team-scoped →
Teams::AwardPointsinteraction resolves the event's Team + originating sub-community, then enqueuesRecomputeLeaderboardJob(team_id, community_id, user_id, period)for each affected scope (team-wide and the originating sub-community) - Job:
- Inserts/updates
TeamLeaderboardEntryfor 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}
- Inserts/updates
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.pointsfield (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:
TeamLeaderboardEntrymodelTeams::AwardPoints,Teams::RecomputeLeaderboardinteractionsRecomputeLeaderboardJob,WeeklyRecapJob(Crono-scheduled, Mondays 9am team TZ)TeamLeaderboardView.vuewith scope toggle (Team-wide / each sub-community user belongs to) + weekly/monthly/all-time toggleLeaderboardEntryRow.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).
8.10 Sub-Community-Scoped Invite Link
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.
- No real-time rank-change push notifications. Members see rank only when they open the app. Period.
- No "X is ahead of you!" social-comparison copy. Leaderboard celebrates contribution, not relative position loss.
- No admin-configured "always-on Slack mirror" that posts every event. Slack integration (V1.2) defaults to digest mode.
- Max one team-scoped push notification per member per day. All other signals are in-app (visible on open) or weekly digest.
- No streak-anxiety mechanics for team goals. Collective goals have a target + deadline, not a "team streak" that can be broken.
- 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.
- 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.
- Notification opt-out is one tap from any team notification. No dark-pattern unsubscribe flows.
- Leaderboard opt-out preserves contributions. Members are never penalized (functionally or socially) for not appearing on the board.
- 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)
| Metric | Target |
|---|---|
| Paying teams | 10–30 |
| MRR | $2,000–$8,000 |
| Avg team size | 6–10 seats |
| Monthly logo churn | <5% |
| Annual revenue retention | ≥95% |
V1 MVP activation (per feature)
| Metric | Target | Source |
|---|---|---|
| Trial → paid conversion | ≥40% within 14 days | Billing (§7.1) |
| Invite link → join | ≥80% within 7 days | Invites (§7.2) |
| Time to first goal event after join | <48 hours median | Invites + onboarding |
| % teams with ≥1 collective goal | ≥40% within 30 days of paid | Collective goals (§7.3) |
| Leaderboard view rate | ≥70% of active members weekly | Leaderboards (§7.4) |
| Leaderboard opt-out rate | ≤10% | Leaderboards (§7.4) |
Engagement (vs. free community baseline)
| Metric | Target | Notes |
|---|---|---|
| Team member DAU/MAU | ≥1.5x baseline | Per north-star.md |
| Team member 30-day retention | ≥1.8x baseline | |
| Team-scoped check-ins per member per week | ≥4 median | Anti-grind: capped at 100 pts/day |
Multi-sub-community adoption (validates the architectural bet)
| Metric | Target | Notes |
|---|---|---|
| % Teams with ≥2 sub-communities at 90 days | ≥25% of Teams ≥10 seats | If <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-default | Validates browser UX |
Trust (data boundary §6)
| Metric | Target |
|---|---|
| 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)
| Metric | Threshold |
|---|---|
| Team-related push-notification opt-outs | <5%/quarter |
| Members reporting "feel surveilled" in NPS comments | Zero tolerance — any signal triggers review |
| Session length increase past 12-min P50 for team members | Stop 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_enabledflag → 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 handlingteams_collective_goals— kill switch for §7.3teams_leaderboards— kill switch for §7.4teams_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
| # | Question | Options | Recommendation | Decision owner | Due |
|---|---|---|---|---|---|
| 1 | Final Teams price | $5 / $7 / $9 per seat/mo | $7/mo, $70/yr, 14-day trial | Josh | Before pricing-page copy (Phase 7 Sprint 1) |
| 2 | Data model: extend Community 1:1 vs. separate Team aggregate that has_many Communities | A: extend 1:1 / C: separate Team + many Communities / D: nested Teams | C: separate Team aggregate, Team has_many :communities (drives multi-sub-community vision; see §5) | Josh | Before first migration (Phase 7 Sprint 1) |
| 3 | Bulk CSV invite — V1 or V1.1? | V1 / V1.1 | V1.1 (coaching ICP doesn't need it; corporate wellness ICP does, but isn't lead) | Josh | Sprint 2 |
| 4 | Mood-trend aggregate visible to admins (anonymized, N≥5)? | Yes / No / Per-team toggle | No (strict reading of data boundary; revisit at SOC 2 phase) | Josh | Before V1.1 admin dashboard sprint |
| 5 | Annual discount magnitude | 10% / 17% / 20% | 17% ($70 vs. $84 — clean round number) | Josh | Same as #1 |
| 6 | Trial requires card? | Yes / No | No (lower friction, better trial→paid conversion in B2C-leaning ICPs) | Josh | Same as #1 |
| 7 | Max team size at launch | 50 / 100 / 200 / unlimited | 200 (covers all V1 ICPs; enterprise tier later for larger) | Josh | Sprint 1 |
| 8 | Timezone handling for weekly leaderboards | Team-level / member-level / hardcoded UTC | Team-level, default America/New_York | Josh | Sprint 3 |
| 9 | Refund policy | None / 7-day / 30-day | 7-day, handled manually via Stripe dashboard | Josh | Before GA |
| 10 | Beta participant compensation | Free during beta / discounted in perpetuity / lifetime free | Free during beta + 50% discount locked for 6 months post-GA (open beta) | Josh | Before open beta |
| 11 | Default sub-community auto-join behavior | Auto-join default community / require explicit pick from browser even for default | Auto-join the default community on invite acceptance; surface browser only when ≥1 non-default sub-community exists | Josh | Sprint 3 |
| 12 | Sub-community archive grace period | Immediate / 7 days / 30 days | 30 days — soft-archive (read-only), then hard-archive (paranoia-deleted); members get one notification on archive | Josh | Sprint 3 |
| 13 | External 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 common | Josh | Before V1.2 External Coach build |
| 14 | Cap on sub-communities per team | None / 5 / 10 / scale with seat count | 10 sub-communities per Team in V1; raise on request after observing usage | Josh | Sprint 3 |
| 15 | Can members create sub-communities (self-serve) or admins only? | Members can / admins only / admins + designated leads | Admins only in V1; designated-leads (delegate) in V1.1 | Josh | Sprint 3 |
| 16 | Sub-community discovery: "open to all team members" vs. invite-only-within-team | All members can browse and join / Lead controls join (request-to-join) / Mix per sub-community | Mix per sub-community — toggle on sub-community creation (default: open within team) | Josh | Sprint 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
| Term | Meaning |
|---|---|
| Team | A Team aggregate record with an active TeamSubscription. Owns one or more Community records (sub-communities). |
| Team Owner | User referenced by Team.billing_owner_id; manages billing; carries Team Admin role automatically via TeamMembership.role == "owner" |
| Team Admin | TeamMembership.role == "admin"; full operational access across the Team (invite, remove, create sub-communities, see aggregate analytics); no billing |
| Team Member | TeamMembership.role == "member"; participates in goals, challenges, leaderboards within sub-communities they've joined |
| External Coach | TeamMembership.role == "external_coach"; V1.2 — read-only observer; free addon (per Open Question #13) |
| TeamMembership | The join record between a User and a Team. Carries the Team-level role. One per (user, team) pair. |
| Sub-Community | A Community owned by a Team (Community.team_id set). Members self-select into one or more sub-communities. |
| Default Community | The 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 Lead | CommunityMember.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 Moderator | CommunityMember.role == "moderator"; can post, encourage, run challenges within that sub-community |
| Collective Goal | A CollectiveGoal record tied to a Team (always) and optionally scoped to a single sub-community. Multiple members contribute. |
| Team Subscription | TeamSubscription record; links Team ↔ Plan ↔ Stripe |
| Seat | One billable user-slot; equals one active TeamMembership (regardless of how many sub-communities that user belongs to) |
| Team Scope | The set of goals, events, badges, and points attributed to activity within this Team or any of its sub-communities |
| Sub-Community Scope | A narrower slice of Team Scope, attributed to a specific sub-community |
| Personal Scope | Everything outside Team Scope — protected from admin visibility per §6 |
| TeamAccessPolicy | Service 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, #58docs/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 targetsMISSION.md— anti-social, no-data-selling commitments (constraint source for §6, §9)docs/brand/brand.md— tone, visual constraintsrails_api/app/models/community.rb,community_member.rb,plan.rb,payment_record.rb— existing schema reusedrails_api/app/services/stripe_service.rb,rails_api/app/interactions/billing/— existing billing infraionic_frontend/src/lib/featureFlags.ts— flag registration targetdocs/development/feature-flags.md— flag lifecycle policydocs/architecture/authentication.md— Clerk integration patterns (Teams uses same)
Appendix A — Pricing comparison
| Tool | Price/user/mo | Annual | Positioning anchor |
|---|---|---|---|
| Slack (Pro) | $7.25 | $87 | The default "we know what this costs" benchmark |
| Asana (Starter) | $10.99 | $131.88 | PM tool — bracket above Objectuve |
| Strava Premium (per user equivalent) | $11.99 (individual) | $79.99 | Closest fitness-accountability frame |
| Habitica Group Plan | $9 (group of 4) | — | Direct adjacent product |
| Stickk | Variable (commitment-based) | — | Adjacent in commitment-device space |
| Objectuve Teams (recommended) | $7 | $70 | Below Slack, above hobby tools |
Appendix B — Competitive feature matrix
| Feature | Objectuve Teams | Slack | Strava Clubs | Asana Goals |
|---|---|---|---|---|
| Personal goal tracking | ✅ Core | ❌ | ✅ Activities | ✅ |
| Team accountability | ✅ Core | Partial | ✅ | ✅ |
| Coach role | ✅ V1.2 | ❌ | ❌ | ❌ |
| Custom badges | ✅ V1.1 | ❌ | ❌ | ❌ |
| Anti-social-app design | ✅ Principle | ❌ | ❌ | ❌ |
| Member privacy from admins | ✅ §6 contract | N/A | Partial | ❌ (work tool) |
| Slack integration | ✅ V1.2 | N/A | ❌ | ✅ |
| Pricing (per seat/mo) | $7 | $7.25 | $11.99 (individual) | $10.99 |
Appendix C — V1 sprint sequencing (high-level, 10 sprints)
| Sprint | Focus | Exit signal |
|---|---|---|
| 1 | Pricing decisions, data model migration, TeamSubscription + Plan rows | Migration on staging; Plans seeded |
| 2 | Billing flow (§7.1), Clerk Billing integration, webhook plumbing | Test-mode subscription completes end-to-end |
| 3 | Private team invites (§7.2), join flow, Members tab | Closed beta cohort can be invited |
| 4 | Team Leaderboards (§7.4), points engine, weekly recap | Leaderboard reads/writes pass load test |
| 5 | Collective goals (§7.3), contribution opt-in flow, progress UI | Closed beta cohort opts in |
| 6 | Closed beta cohort onboarded; feedback intake; bug fixes | All 5 teams active for 14 days |
| 7 | V1.1 prep: custom badges, team admin dashboard analytics | Open beta cohort onboarded |
| 8 | V1.1: accountability pair auto-matching, manager view | Manager view shipped |
| 9 | V1.1: priority support routing; rollout polish | Support queue live |
| 10 | GA: 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.