fix(billing): make subscription table source of truth for period start and period end (#1114)

* fix(billing): vercel cron not processing billing periods

* fix(billing): cleanup unused POST and fix bug with billing timing check

* make subscriptions table source of truth for dates

* update org routes

* make everything dependent on stripe webhook

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
This commit is contained in:
Vikhyath Mondreti
2025-08-23 10:25:41 -07:00
committed by GitHub
parent 917552f041
commit 780870c48e
9 changed files with 109 additions and 220 deletions

View File

@@ -1,150 +0,0 @@
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { processDailyBillingCheck } from '@/lib/billing/core/billing'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('DailyBillingCron')
/**
* Daily billing CRON job endpoint that checks individual billing periods
*/
export async function POST(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check')
if (authError) {
return authError
}
logger.info('Starting daily billing check cron job')
const startTime = Date.now()
// Process overage billing for users and organizations with periods ending today
const result = await processDailyBillingCheck()
const duration = Date.now() - startTime
if (result.success) {
logger.info('Daily billing check completed successfully', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
})
return NextResponse.json({
success: true,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
},
})
}
logger.error('Daily billing check completed with errors', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
errors: result.errors,
duration: `${duration}ms`,
})
return NextResponse.json(
{
success: false,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
duration: `${duration}ms`,
},
errors: result.errors,
},
{ status: 500 }
)
} catch (error) {
logger.error('Fatal error in daily billing cron job', { error })
return NextResponse.json(
{
success: false,
error: 'Internal server error during daily billing check',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}
/**
* GET endpoint for manual testing and health checks
*/
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'daily billing check health check')
if (authError) {
return authError
}
const startTime = Date.now()
const result = await processDailyBillingCheck()
const duration = Date.now() - startTime
if (result.success) {
logger.info('Daily billing check (GET) completed successfully', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
})
return NextResponse.json({
success: true,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
duration: `${duration}ms`,
},
})
}
logger.error('Daily billing check (GET) completed with errors', {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
errors: result.errors,
duration: `${duration}ms`,
})
return NextResponse.json(
{
success: false,
summary: {
processedUsers: result.processedUsers,
processedOrganizations: result.processedOrganizations,
totalChargedAmount: result.totalChargedAmount,
errorCount: result.errors.length,
duration: `${duration}ms`,
},
errors: result.errors,
},
{ status: 500 }
)
} catch (error) {
logger.error('Fatal error in daily billing (GET) cron job', { error })
return NextResponse.json(
{
success: false,
error: 'Internal server error during daily billing check',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -1,6 +1,7 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, user, userStats } from '@/db/schema'
@@ -80,8 +81,6 @@ export async function GET(
.select({
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
lastPeriodCost: userStats.lastPeriodCost,
@@ -90,11 +89,22 @@ export async function GET(
.where(eq(userStats.userId, memberId))
.limit(1)
const computed = await getUserUsageData(memberId)
if (usageData.length > 0) {
memberData = {
...memberData,
usage: usageData[0],
} as typeof memberData & { usage: (typeof usageData)[0] }
usage: {
...usageData[0],
billingPeriodStart: computed.billingPeriodStart,
billingPeriodEnd: computed.billingPeriodEnd,
},
} as typeof memberData & {
usage: (typeof usageData)[0] & {
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
}
}
}
}

View File

@@ -3,6 +3,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails/render-email'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
import { sendEmail } from '@/lib/email/mailer'
import { quickValidateEmail } from '@/lib/email/validation'
@@ -63,7 +64,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Include usage data if requested and user has admin access
if (includeUsage && hasAdminAccess) {
const membersWithUsage = await db
const base = await db
.select({
id: member.id,
userId: member.userId,
@@ -74,8 +75,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
userEmail: user.email,
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
usageLimitSetBy: userStats.usageLimitSetBy,
usageLimitUpdatedAt: userStats.usageLimitUpdatedAt,
})
@@ -84,6 +83,17 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.leftJoin(userStats, eq(user.id, userStats.userId))
.where(eq(member.organizationId, organizationId))
const membersWithUsage = await Promise.all(
base.map(async (row) => {
const usage = await getUserUsageData(row.userId)
return {
...row,
billingPeriodStart: usage.billingPeriodStart,
billingPeriodEnd: usage.billingPeriodEnd,
}
})
)
return NextResponse.json({
success: true,
data: membersWithUsage,

View File

@@ -145,12 +145,9 @@ export async function initializeBillingPeriod(
end = billingPeriod.end
}
// Update user stats with billing period info
await db
.update(userStats)
.set({
billingPeriodStart: start,
billingPeriodEnd: end,
currentPeriodCost: '0',
})
.where(eq(userStats.userId, userId))
@@ -212,14 +209,12 @@ export async function resetUserBillingPeriod(userId: string): Promise<void> {
newPeriodEnd = billingPeriod.end
}
// Archive current period cost and reset for new period
// Archive current period cost and reset for new period (no longer updating period dates in user_stats)
await db
.update(userStats)
.set({
lastPeriodCost: currentPeriodCost, // Archive previous period
currentPeriodCost: '0', // Reset to zero for new period
billingPeriodStart: newPeriodStart,
billingPeriodEnd: newPeriodEnd,
lastPeriodCost: currentPeriodCost,
currentPeriodCost: '0',
})
.where(eq(userStats.userId, userId))

View File

@@ -9,7 +9,7 @@ import { getUserUsageData } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { member, organization, subscription, user, userStats } from '@/db/schema'
import { member, organization, subscription, user } from '@/db/schema'
const logger = createLogger('Billing')
@@ -673,15 +673,14 @@ export async function getUsersAndOrganizationsForOverageBilling(): Promise<{
continue // Skip free plans
}
// Check if subscription period ends today
// Check if subscription period ends today (range-based, inclusive of day)
let shouldBillToday = false
if (sub.periodEnd) {
const periodEnd = new Date(sub.periodEnd)
periodEnd.setUTCHours(0, 0, 0, 0) // Normalize to start of day
const endsToday = periodEnd >= today && periodEnd < tomorrow
// Bill if the subscription period ends today
if (periodEnd.getTime() === today.getTime()) {
if (endsToday) {
shouldBillToday = true
logger.info('Subscription period ends today', {
referenceId: sub.referenceId,
@@ -689,29 +688,6 @@ export async function getUsersAndOrganizationsForOverageBilling(): Promise<{
periodEnd: sub.periodEnd,
})
}
} else {
// Fallback: Check userStats billing period for users
const userStatsRecord = await db
.select({
billingPeriodEnd: userStats.billingPeriodEnd,
})
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
if (userStatsRecord.length > 0 && userStatsRecord[0].billingPeriodEnd) {
const billingPeriodEnd = new Date(userStatsRecord[0].billingPeriodEnd)
billingPeriodEnd.setUTCHours(0, 0, 0, 0) // Normalize to start of day
if (billingPeriodEnd.getTime() === today.getTime()) {
shouldBillToday = true
logger.info('User billing period ends today (from userStats)', {
userId: sub.referenceId,
plan: sub.plan,
billingPeriodEnd: userStatsRecord[0].billingPeriodEnd,
})
}
}
}
if (shouldBillToday) {

View File

@@ -94,8 +94,6 @@ export async function getOrganizationBillingData(
// User stats fields
currentPeriodCost: userStats.currentPeriodCost,
currentUsageLimit: userStats.currentUsageLimit,
billingPeriodStart: userStats.billingPeriodStart,
billingPeriodEnd: userStats.billingPeriodEnd,
lastActive: userStats.lastActive,
})
.from(member)
@@ -151,10 +149,9 @@ export async function getOrganizationBillingData(
const averageUsagePerMember = members.length > 0 ? totalCurrentUsage / members.length : 0
// Get billing period from first member (should be consistent across org)
const firstMember = membersWithUsage[0]
const billingPeriodStart = firstMember?.billingPeriodStart || null
const billingPeriodEnd = firstMember?.billingPeriodEnd || null
// Billing period comes from the organization's subscription
const billingPeriodStart = subscription.periodStart || null
const billingPeriodEnd = subscription.periodEnd || null
return {
organizationId,

View File

@@ -41,6 +41,7 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
}
const stats = userStatsData[0]
const subscription = await getHighestPrioritySubscription(userId)
const currentUsage = Number.parseFloat(
stats.currentPeriodCost?.toString() ?? stats.totalCost.toString()
)
@@ -49,14 +50,19 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
const isWarning = percentUsed >= 80
const isExceeded = currentUsage >= limit
// Derive billing period dates from subscription (source of truth).
// For free users or missing dates, expose nulls.
const billingPeriodStart = subscription?.periodStart ?? null
const billingPeriodEnd = subscription?.periodEnd ?? null
return {
currentUsage,
limit,
percentUsed,
isWarning,
isExceeded,
billingPeriodStart: stats.billingPeriodStart,
billingPeriodEnd: stats.billingPeriodEnd,
billingPeriodStart,
billingPeriodEnd,
lastPeriodCost: Number.parseFloat(stats.lastPeriodCost?.toString() || '0'),
}
} catch (error) {

View File

@@ -1,5 +1,12 @@
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import {
resetOrganizationBillingPeriod,
resetUserBillingPeriod,
} from '@/lib/billing/core/billing-periods'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { subscription as subscriptionTable } from '@/db/schema'
const logger = createLogger('StripeInvoiceWebhooks')
@@ -99,27 +106,69 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
try {
const invoice = event.data.object as Stripe.Invoice
// Check if this is an overage billing invoice
if (invoice.metadata?.type !== 'overage_billing') {
logger.info('Ignoring non-overage billing invoice finalization', { invoiceId: invoice.id })
// Case 1: Overage invoices (metadata.type === 'overage_billing')
if (invoice.metadata?.type === 'overage_billing') {
const customerId = invoice.customer as string
const invoiceAmount = invoice.amount_due / 100
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
logger.info('Overage billing invoice finalized', {
invoiceId: invoice.id,
customerId,
invoiceAmount,
billingPeriod,
customerEmail: invoice.customer_email,
hostedInvoiceUrl: invoice.hosted_invoice_url,
})
return
}
const customerId = invoice.customer as string
const invoiceAmount = invoice.amount_due / 100 // Convert from cents to dollars
const billingPeriod = invoice.metadata?.billingPeriod || 'unknown'
// Case 2: Subscription cycle invoices (primary period rollover)
// When an invoice is finalized for a subscription cycle, align our usage reset to this boundary
if (invoice.subscription) {
const stripeSubscriptionId = String(invoice.subscription)
logger.info('Overage billing invoice finalized', {
const records = await db
.select()
.from(subscriptionTable)
.where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId))
.limit(1)
if (records.length === 0) {
logger.warn('No matching internal subscription for Stripe invoice subscription', {
invoiceId: invoice.id,
stripeSubscriptionId,
})
return
}
const sub = records[0]
// Idempotent reset aligned to the subscriptions new cycle
if (sub.plan === 'team' || sub.plan === 'enterprise') {
await resetOrganizationBillingPeriod(sub.referenceId)
logger.info('Reset organization billing period on subscription invoice finalization', {
invoiceId: invoice.id,
organizationId: sub.referenceId,
plan: sub.plan,
})
} else {
await resetUserBillingPeriod(sub.referenceId)
logger.info('Reset user billing period on subscription invoice finalization', {
invoiceId: invoice.id,
userId: sub.referenceId,
plan: sub.plan,
})
}
return
}
logger.info('Ignoring non-subscription invoice finalization', {
invoiceId: invoice.id,
customerId,
invoiceAmount,
billingPeriod,
customerEmail: invoice.customer_email,
hostedInvoiceUrl: invoice.hosted_invoice_url,
billingReason: invoice.billing_reason,
})
// Additional invoice finalization logic can be added here
// For example: update internal records, trigger notifications, etc.
} catch (error) {
logger.error('Failed to handle invoice finalized', {
eventId: event.id,

View File

@@ -15,10 +15,6 @@
{
"path": "/api/logs/cleanup",
"schedule": "0 0 * * *"
},
{
"path": "/api/billing/daily",
"schedule": "0 2 * * *"
}
]
}