Skip to main content

Billing Cycle Management

Reportr implements a 30-day rolling billing cycle system for fair usage tracking across different subscription tiers. This system automatically resets usage limits every 30 days from the user’s start date.

Architecture Overview

Core Module

Location: src/lib/billing-cycle.ts Purpose: Manages 30-day rolling billing cycles for report generation limits Key Concepts:
  • Rolling Cycle - 30 days from cycle start, not calendar month
  • Automatic Reset - Checks and resets on every usage check
  • Fair Allocation - Users get full 30 days regardless of signup date

Database Schema

User Model Fields:
model User {
  id                  String
  plan                Plan       @default(FREE)
  billingCycleStart   DateTime
  billingCycleEnd     DateTime
  // ... other fields
}

model Report {
  userId     String
  createdAt  DateTime  @default(now())
  // ... other fields
}

enum Plan {
  FREE
  STARTER
  PROFESSIONAL
  AGENCY
}

Core Functions

checkAndResetBillingCycle()

Checks if a user’s billing cycle needs reset and resets if necessary. Location: src/lib/billing-cycle.ts:20
export async function checkAndResetBillingCycle(userId: string): Promise<boolean> {
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      id: true,
      billingCycleStart: true,
      billingCycleEnd: true,
    }
  });

  if (!user) {
    throw new Error('User not found');
  }

  const now = new Date();
  
  // Check if cycle needs reset
  if (!user.billingCycleEnd || now > user.billingCycleEnd) {
    const newCycleStart = now;
    const newCycleEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
    
    await prisma.user.update({
      where: { id: userId },
      data: {
        billingCycleStart: newCycleStart,
        billingCycleEnd: newCycleEnd,
      }
    });
    
    console.log(`Billing cycle reset for user ${userId}`);
    return true;
  }
  
  return false;
}
Returns: true if cycle was reset, false if no reset needed When Called:
  • Before checking usage limits
  • When displaying usage statistics
  • Before creating new reports

getBillingCycleInfo()

Retrieves current billing cycle information for a user. Location: src/lib/billing-cycle.ts:67
export interface BillingCycleInfo {
  cycleStart: Date;
  cycleEnd: Date;
  daysRemaining: number;
  cycleWasReset: boolean;
}

export async function getBillingCycleInfo(userId: string): Promise<BillingCycleInfo> {
  // First check and reset if needed
  const cycleWasReset = await checkAndResetBillingCycle(userId);
  
  // Get updated user data
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: {
      billingCycleStart: true,
      billingCycleEnd: true,
    }
  });

  if (!user || !user.billingCycleEnd) {
    throw new Error('User billing cycle not properly initialized');
  }

  const now = new Date();
  const daysRemaining = Math.max(0, Math.ceil(
    (user.billingCycleEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
  ));

  return {
    cycleStart: user.billingCycleStart,
    cycleEnd: user.billingCycleEnd,
    daysRemaining,
    cycleWasReset
  };
}
Usage Example:
const cycleInfo = await getBillingCycleInfo(userId);

console.log(`Cycle: ${cycleInfo.cycleStart} to ${cycleInfo.cycleEnd}`);
console.log(`Days remaining: ${cycleInfo.daysRemaining}`);
if (cycleInfo.cycleWasReset) {
  console.log('🔄 Usage limits have been reset!');
}

getReportsInCurrentCycle()

Counts reports generated in the current billing cycle. Location: src/lib/billing-cycle.ts:102
export async function getReportsInCurrentCycle(userId: string): Promise<number> {
  const cycleInfo = await getBillingCycleInfo(userId);

  return await prisma.report.count({
    where: {
      userId,
      createdAt: {
        gte: cycleInfo.cycleStart,
        lt: cycleInfo.cycleEnd,
      },
    },
  });
}
Returns: Number of reports created in current cycle Usage Example:
const reportsUsed = await getReportsInCurrentCycle(userId);
const limit = getPlanLimit(user.plan);

if (reportsUsed >= limit) {
  throw new Error('Report limit reached for this billing cycle');
}

getUsageStats()

Retrieves comprehensive usage statistics for a user. Location: src/lib/billing-cycle.ts:167
export interface UsageStats {
  reportsUsed: number;
  reportsLimit: number;
  reportsRemaining: number;
  daysRemaining: number;
  cycleStart: Date;
  cycleEnd: Date;
  utilizationPercentage: number;
}

export async function getUsageStats(userId: string, userPlan: string): Promise<UsageStats> {
  const cycleInfo = await getBillingCycleInfo(userId);
  const reportsUsed = await getReportsInCurrentCycle(userId);
  
  // Define tier limits
  const limits = {
    FREE: 5,
    STARTER: 25,
    PROFESSIONAL: 75,
    AGENCY: 250
  } as const;

  const reportsLimit = limits[userPlan as keyof typeof limits] || limits.FREE;
  const reportsRemaining = Math.max(0, reportsLimit - reportsUsed);
  const utilizationPercentage = Math.round((reportsUsed / reportsLimit) * 100);

  return {
    reportsUsed,
    reportsLimit,
    reportsRemaining,
    daysRemaining: cycleInfo.daysRemaining,
    cycleStart: cycleInfo.cycleStart,
    cycleEnd: cycleInfo.cycleEnd,
    utilizationPercentage
  };
}
Usage Example:
const stats = await getUsageStats(user.id, user.plan);

// Display in UI
<UsageCard
  used={stats.reportsUsed}
  limit={stats.reportsLimit}
  remaining={stats.reportsRemaining}
  daysLeft={stats.daysRemaining}
  percentage={stats.utilizationPercentage}
/>

initializeBillingCycle()

Initializes billing cycle for a new user. Location: src/lib/billing-cycle.ts:134
export async function initializeBillingCycle(userId: string): Promise<void> {
  const now = new Date();
  const cycleEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);

  await prisma.user.update({
    where: { id: userId },
    data: {
      billingCycleStart: now,
      billingCycleEnd: cycleEnd,
    }
  });

  console.log(`Initialized billing cycle for user ${userId}:`, {
    start: now,
    end: cycleEnd
  });
}
When Called:
  • During user registration (after signup)
  • When upgrading from trial

getDaysUntilReset()

Calculates days remaining until billing cycle reset. Location: src/lib/billing-cycle.ts:121
export function getDaysUntilReset(billingCycleEnd: Date | null): number {
  if (!billingCycleEnd) return 0;
  
  const now = new Date();
  const diff = billingCycleEnd.getTime() - now.getTime();
  return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
}
Returns: Number of days until reset (0 if already expired)

Plan Limits

Tier Configuration

Location: src/lib/billing-cycle.ts:172
const limits = {
  FREE: 5,           // 5 reports per 30 days
  STARTER: 25,       // 25 reports per 30 days
  PROFESSIONAL: 75,  // 75 reports per 30 days
  AGENCY: 250        // 250 reports per 30 days
} as const;

Plan Comparison

PlanReports/CycleDaily AverageCost/Report*
FREE5~0.17$0
STARTER25~0.83$1.20
PROFESSIONAL75~2.5$0.67
AGENCY250~8.3$0.40
*Based on hypothetical pricing

Integration Points

Report Generation Checks

API Route: src/app/api/reports/generate/route.ts (planned)
export async function POST(req: Request) {
  const session = await getServerSession(authOptions);
  const user = await prisma.user.findUnique({ where: { id: session.user.id } });
  
  // Check usage limits
  const stats = await getUsageStats(user.id, user.plan);
  
  if (stats.reportsRemaining <= 0) {
    return new Response(
      JSON.stringify({ 
        error: 'Report limit reached',
        resetDate: stats.cycleEnd,
        daysRemaining: stats.daysRemaining
      }),
      { status: 429 }
    );
  }
  
  // Generate report...
}

Dashboard Display

Component: src/components/organisms/UsageCard.tsx (planned)
import { getUsageStats } from '@/lib/billing-cycle';

export async function UsageCard({ userId, plan }: Props) {
  const stats = await getUsageStats(userId, plan);
  
  return (
    <div className="usage-card">
      <h3>Usage This Cycle</h3>
      
      <ProgressBar 
        value={stats.reportsUsed} 
        max={stats.reportsLimit}
        percentage={stats.utilizationPercentage}
      />
      
      <div className="stats">
        <span>{stats.reportsUsed} / {stats.reportsLimit} reports used</span>
        <span>{stats.reportsRemaining} remaining</span>
      </div>
      
      <div className="cycle-info">
        <span>Resets in {stats.daysRemaining} days</span>
        <span className="text-sm text-gray-500">
          {formatDate(stats.cycleEnd)}
        </span>
      </div>
    </div>
  );
}

Middleware Protection

Middleware: src/middleware.ts (planned)
export async function middleware(request: NextRequest) {
  // Check if user is at limit for report generation routes
  if (request.url.includes('/api/reports/generate')) {
    const session = await getToken({ req: request });
    const user = await prisma.user.findUnique({ 
      where: { id: session.sub } 
    });
    
    const stats = await getUsageStats(user.id, user.plan);
    
    if (stats.reportsRemaining <= 0) {
      return new Response(
        JSON.stringify({ error: 'Usage limit exceeded' }),
        { status: 429 }
      );
    }
  }
  
  return NextResponse.next();
}

Upgrade/Downgrade Handling

Plan Changes

When user upgrades or downgrades:
async function handlePlanChange(userId: string, newPlan: Plan) {
  // Update user plan
  await prisma.user.update({
    where: { id: userId },
    data: { plan: newPlan }
  });
  
  // DON'T reset billing cycle - let it continue
  // User keeps current cycle dates but gets new limits
  
  console.log(`User ${userId} changed to ${newPlan} plan`);
  console.log('Billing cycle maintained, new limits apply immediately');
}
Philosophy: Maintain cycle dates, change limits immediately

Edge Cases

First User Creation

Problem: New users have null billing cycle dates
Solution: Initialize cycle in signup flow
// In NextAuth callbacks or registration API
async function onUserCreate(user: User) {
  await initializeBillingCycle(user.id);
}

Clock Skew

Problem: Server time vs database time differences
Solution: Always use database-generated timestamps
model Report {
  createdAt DateTime @default(now()) @db.Timestamptz
}

Timezone Handling

Problem: Users in different timezones
Solution: Store all dates in UTC, display in user’s timezone
// Store in UTC
const cycleEnd = new Date(Date.UTC(...));

// Display in user's timezone
const displayDate = cycleEnd.toLocaleString('en-US', {
  timeZone: user.timezone || 'UTC'
});

Concurrent Report Generation

Problem: Race condition when multiple reports created simultaneously
Solution: Database-level checks or row locking
// Use transaction with serializable isolation
await prisma.$transaction(async (tx) => {
  const count = await tx.report.count({
    where: { userId, createdAt: { gte: cycleStart, lt: cycleEnd } }
  });
  
  if (count >= limit) {
    throw new Error('Limit exceeded');
  }
  
  await tx.report.create({ data: reportData });
}, {
  isolationLevel: 'Serializable'
});

Performance Considerations

Caching Strategy

Cache cycle info to reduce database queries:
import { Redis } from '@upstash/redis';

const redis = new Redis(...);

async function getCachedCycleInfo(userId: string): Promise<BillingCycleInfo | null> {
  const cached = await redis.get(`cycle:${userId}`);
  return cached ? JSON.parse(cached) : null;
}

async function setCachedCycleInfo(userId: string, info: BillingCycleInfo) {
  await redis.set(`cycle:${userId}`, JSON.stringify(info), {
    ex: 3600 // Cache for 1 hour
  });
}

Query Optimization

Index on createdAt for fast counting:
model Report {
  userId    String
  createdAt DateTime
  
  @@index([userId, createdAt])
}

Testing

Unit Tests

import { checkAndResetBillingCycle, getUsageStats } from '@/lib/billing-cycle';

describe('Billing Cycle', () => {
  it('resets cycle after 30 days', async () => {
    // Create user with expired cycle
    const user = await createTestUser({
      billingCycleEnd: new Date(Date.now() - 86400000) // Yesterday
    });
    
    const wasReset = await checkAndResetBillingCycle(user.id);
    
    expect(wasReset).toBe(true);
  });
  
  it('calculates usage correctly', async () => {
    const user = await createTestUser({ plan: 'STARTER' });
    await createTestReports(user.id, 10);
    
    const stats = await getUsageStats(user.id, user.plan);
    
    expect(stats.reportsUsed).toBe(10);
    expect(stats.reportsLimit).toBe(25);
    expect(stats.reportsRemaining).toBe(15);
    expect(stats.utilizationPercentage).toBe(40);
  });
});

Monitoring

Metrics to Track

  1. Cycle Resets - Count of automatic resets per day
  2. Limit Breaches - Failed report attempts due to limits
  3. Utilization - Average % of limit used per plan
  4. Reset Lag - Time between cycle expiry and actual reset

Alert Conditions

// Alert if many users hitting limits
if (limitBreachRate > 0.15) {
  alert('15%+ of users hitting report limits');
}

// Alert if cycle resets failing
if (resetFailureRate > 0.01) {
  alert('Billing cycle resets failing');
}

Build docs developers (and LLMs) love