mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-10 23:48:09 -05:00
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:
committed by
GitHub
parent
917552f041
commit
780870c48e
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 subscription’s 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,
|
||||
|
||||
@@ -15,10 +15,6 @@
|
||||
{
|
||||
"path": "/api/logs/cleanup",
|
||||
"schedule": "0 0 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/billing/daily",
|
||||
"schedule": "0 2 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user