Skip to content

Team & Billing Architecture Redesign

Summary

Redesign the billing model to use a unified "Personal Team" pattern where all billing flows through teams, with support for: - Monthly budget caps per team member - Existing users joining teams while keeping their personal accounts - Email invitations via Resend

Current Problems

  1. Dual balance confusion: Both User.balanceCents and Team.balanceCents exist
  2. No budget control: Team owners can't limit member spending
  3. No email invites: Team invitation system not implemented
  4. Unclear team creation: Teams only created during Stripe flow

Proposed Architecture

User Registration Flow

User Registration Flow

Team Invitation Flow

Team Invitation Flow

Data Model

Data Model


Schema Changes

Remove User.balanceCents

model User {
  // REMOVE: balanceCents Int @default(0)
  // Balance now lives only on Team
}

Update TeamMember with Budget Controls

model TeamMember {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  role         TeamRole @default(MEMBER)
  rateLimitRpm Int      @default(0)

  // NEW: Monthly budget controls
  monthlyBudgetCents  Int?      // null = unlimited (uses team balance)
  usedThisMonthCents  Int       @default(0)
  budgetResetAt       DateTime? // When to reset usage counter

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  teamId String
  team   Team   @relation(fields: [teamId], references: [id], onDelete: Cascade)

  @@unique([userId, teamId])
}

Add Active Team to User

model User {
  // ... existing fields

  // NEW: Track which team the user is currently using
  activeTeamId String?
  activeTeam   Team?   @relation("ActiveTeam", fields: [activeTeamId], references: [id])
}

Edge Cases

Case 1: New User Signs Up

  1. User creates account via OAuth
  2. System auto-creates "Personal Team" with user as owner
  3. Team starts with $0 balance
  4. User adds funds via Stripe → credits Team.balanceCents

Case 2: Existing User Invited to Another Team

  1. User A (owner) invites User B (existing account)
  2. User B receives email invitation
  3. User B clicks link, accepts invitation
  4. User B now belongs to TWO teams:
  5. Their personal team (still exists, still has their balance)
  6. User A's team (as a member)
  7. User B can switch between teams in the UI
  8. API calls use the activeTeamId to determine billing

Case 3: User Hits Monthly Budget

  1. TeamMember has monthlyBudgetCents: 5000 ($50)
  2. User makes API calls, usedThisMonthCents increments
  3. When usedThisMonthCents >= monthlyBudgetCents:
  4. API returns 402 "Monthly budget exceeded"
  5. UI shows red indicator
  6. Team owner can increase budget anytime
  7. On budgetResetAt, system resets usedThisMonthCents to 0

Case 4: 5 Individual Users Join One Team

User Before After
A (manager) Has personal team with $100 Creates/uses "Company Team"
B Has personal team with $20 Joins Company Team, keeps personal team
C Has personal team with $0 Joins Company Team, keeps personal team
D Has personal team with $50 Joins Company Team, keeps personal team
E New user Gets invite, signs up, joins Company Team
  • Each user's personal balance remains untouched
  • Company Team has its own balance (funded by A)
  • When users work in Company Team, it debits from Company Team balance
  • Users can switch to personal team to use their own balance

API Changes

GET /api/user/status

// Response now includes active team info
{
  email: string,
  name: string,
  hasSubscription: boolean,  // activeTeam.balanceCents > 0
  apiKey: string,
  activeTeam: {
    id: string,
    name: string,
    balanceCents: number,
    role: "ADMIN" | "MEMBER"
  },
  teams: [...]  // All teams user belongs to
}

POST /api/team/switch

Switch the user's active team:

// Request
{ teamId: string }

// Response
{ success: true, activeTeam: {...} }

POST /api/team/invite

Send email invitation:

// Request
{
  email: string,
  role?: "ADMIN" | "MEMBER",
  monthlyBudgetCents?: number
}

// Response
{ success: true, invitationId: string }

POST /api/team/member/budget

Update member's monthly budget:

// Request
{
  memberId: string,
  monthlyBudgetCents: number | null  // null = unlimited
}

Email Setup (Resend)

Why Resend over GSuite

Feature Resend GSuite
Purpose Transactional emails Business email
Pricing Free: 100/day, $20/mo: 50k $6/user/month
Setup API key, 5 minutes DNS, admin console
Best for Invites, notifications Human correspondence

Implementation

npm install resend
// lib/email.ts
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendTeamInvite(email: string, teamName: string, inviteUrl: string) {
  await resend.emails.send({
    from: 'Fabric <noreply@codewithfabric.com>',
    to: email,
    subject: `You've been invited to join ${teamName} on Fabric`,
    html: `...`
  });
}

DNS Records for codewithfabric.com

Add these records in your DNS provider:

Type: TXT
Name: @
Value: v=spf1 include:resend.com ~all

Type: TXT
Name: resend._domainkey
Value: (provided by Resend dashboard)

Migration Plan

Step 1: Schema Migration

  1. Add new fields to TeamMember (budget controls)
  2. Add activeTeamId to User
  3. Keep User.balanceCents temporarily (for migration)

Step 2: Data Migration

-- For each user without a team, create a personal team
INSERT INTO "Team" (id, name, "ownerId", "balanceCents", ...)
SELECT
  gen_random_uuid(),
  name || '''s Team',
  id,
  "balanceCents",  -- Move user balance to team
  ...
FROM "User" u
WHERE NOT EXISTS (SELECT 1 FROM "Team" t WHERE t."ownerId" = u.id);

-- Set activeTeamId to their personal team
UPDATE "User" u
SET "activeTeamId" = (SELECT id FROM "Team" t WHERE t."ownerId" = u.id LIMIT 1);

Step 3: Update Billing Logic

  1. Update /api/user/status to use activeTeam.balanceCents
  2. Update /api/keys/generate similarly
  3. Update LiteLLM callback to debit from activeTeam

Step 4: Remove User.balanceCents

After verifying migration:

model User {
  // REMOVE: balanceCents Int @default(0)
}

Files to Modify

File Changes
prisma/schema.prisma Add budget fields, activeTeamId, remove User.balanceCents
src/app/api/user/status/route.ts Use activeTeam for billing check
src/app/api/keys/generate/route.ts Use activeTeam balance
src/app/api/team/invite/route.ts NEW: Send email invitations
src/app/api/team/switch/route.ts NEW: Switch active team
src/app/api/team/member/budget/route.ts NEW: Update member budget
src/app/dashboard/team/page.tsx Add budget controls UI
src/lib/email.ts NEW: Resend integration
src/lib/litellm.ts Update to check member budget

Test Scenarios

  1. New user signup → personal team created
  2. User adds funds → team balance increases
  3. User A invites User B → email sent, B can accept
  4. User B accepts → B in both teams, can switch
  5. Owner sets $50 budget for member → enforced on API calls
  6. Member exceeds budget → 402 error, red UI indicator
  7. Owner increases budget → member can continue
  8. Month ends → budget resets automatically