d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
728 lines
24 KiB
TypeScript
728 lines
24 KiB
TypeScript
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||
import { db } from '@sim/db'
|
||
import { member, organization, subscription, userStats } from '@sim/db/schema'
|
||
import { createLogger } from '@sim/logger'
|
||
import { and, eq, sql } from 'drizzle-orm'
|
||
import { BILLING_LOCK_TIMEOUT_MS, DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
|
||
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
|
||
import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing'
|
||
import {
|
||
getHighestPrioritySubscription,
|
||
getOrganizationSubscriptionUsable,
|
||
} from '@/lib/billing/core/subscription'
|
||
import { getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
|
||
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
|
||
import {
|
||
hasUsableSubscriptionAccess,
|
||
isOrgScopedSubscription,
|
||
} from '@/lib/billing/subscriptions/utils'
|
||
import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
|
||
import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
|
||
import { env, envNumber } from '@/lib/core/config/env'
|
||
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
|
||
import { captureServerEvent } from '@/lib/posthog/server'
|
||
|
||
const logger = createLogger('ThresholdBilling')
|
||
|
||
const OVERAGE_THRESHOLD = envNumber(env.OVERAGE_THRESHOLD_DOLLARS, DEFAULT_OVERAGE_THRESHOLD)
|
||
const USAGE_TOTAL_EPSILON = 0.000001
|
||
|
||
interface PersonalUsageSnapshot {
|
||
currentPeriodCost: number
|
||
proPeriodCostSnapshot: number
|
||
proPeriodCostSnapshotAt: Date | null
|
||
lastPeriodCost: number
|
||
}
|
||
|
||
interface OrganizationUsageSnapshot {
|
||
memberIds: string[]
|
||
ownerId: string
|
||
memberSignature: string
|
||
pooledCurrentPeriodCost: number
|
||
departedMemberUsage: number
|
||
}
|
||
|
||
export async function checkAndBillOverageThreshold(userId: string): Promise<void> {
|
||
try {
|
||
const threshold = OVERAGE_THRESHOLD
|
||
|
||
const userSubscription = await getHighestPrioritySubscription(userId)
|
||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||
|
||
if (
|
||
!userSubscription ||
|
||
!hasUsableSubscriptionAccess(userSubscription.status, billingStatus.billingBlocked)
|
||
) {
|
||
logger.debug('No active subscription for threshold billing', { userId })
|
||
return
|
||
}
|
||
|
||
if (isFree(userSubscription.plan) || isEnterprise(userSubscription.plan)) {
|
||
return
|
||
}
|
||
|
||
// Org-scoped subs are billed at the org level regardless of plan name.
|
||
if (isOrgScopedSubscription(userSubscription, userId)) {
|
||
logger.debug('Org-scoped subscription detected - triggering org-level threshold billing', {
|
||
userId,
|
||
organizationId: userSubscription.referenceId,
|
||
plan: userSubscription.plan,
|
||
})
|
||
await checkAndBillOrganizationOverageThreshold(userSubscription.referenceId)
|
||
return
|
||
}
|
||
|
||
const usageSnapshot = await getPersonalUsageSnapshot(userId)
|
||
if (!usageSnapshot) {
|
||
logger.warn('User stats not found for threshold billing', { userId })
|
||
return
|
||
}
|
||
|
||
const currentOverage = await calculateSubscriptionOverage({
|
||
id: userSubscription.id,
|
||
plan: userSubscription.plan,
|
||
referenceId: userSubscription.referenceId,
|
||
seats: userSubscription.seats,
|
||
periodStart: userSubscription.periodStart,
|
||
periodEnd: userSubscription.periodEnd,
|
||
})
|
||
|
||
if (currentOverage < threshold) {
|
||
logger.debug('Threshold billing check below threshold before locking user stats', {
|
||
userId,
|
||
plan: userSubscription.plan,
|
||
currentOverage,
|
||
threshold,
|
||
})
|
||
return
|
||
}
|
||
|
||
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
|
||
if (!stripeSubscriptionId) {
|
||
logger.error('No Stripe subscription ID found', { userId })
|
||
return
|
||
}
|
||
|
||
const customerRows = await db
|
||
.select({ stripeCustomerId: subscription.stripeCustomerId })
|
||
.from(subscription)
|
||
.where(eq(subscription.id, userSubscription.id))
|
||
.limit(1)
|
||
const customerId = customerRows[0]?.stripeCustomerId
|
||
if (!customerId) {
|
||
logger.error('No Stripe customer ID found', { userId, subscriptionId: userSubscription.id })
|
||
return
|
||
}
|
||
|
||
const periodEnd = userSubscription.periodEnd
|
||
? Math.floor(userSubscription.periodEnd.getTime() / 1000)
|
||
: Math.floor(Date.now() / 1000)
|
||
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
||
const totalOverageCents = Math.round(currentOverage * 100)
|
||
|
||
const billedResult = await db.transaction(
|
||
async (
|
||
tx
|
||
): Promise<{
|
||
amount: number
|
||
creditsApplied: number
|
||
settledVia: 'stripe' | 'credits'
|
||
} | null> => {
|
||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||
|
||
const statsRecords = await tx
|
||
.select()
|
||
.from(userStats)
|
||
.where(eq(userStats.userId, userId))
|
||
.for('update')
|
||
.limit(1)
|
||
|
||
if (statsRecords.length === 0) {
|
||
logger.warn('User stats not found for threshold billing', { userId })
|
||
return null
|
||
}
|
||
|
||
const stats = statsRecords[0]
|
||
const lockedUsageSnapshot = personalUsageSnapshotFromStats(stats)
|
||
if (!personalUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)) {
|
||
logger.debug('Personal usage changed during threshold billing check; retry later', {
|
||
userId,
|
||
usageSnapshot,
|
||
lockedUsageSnapshot,
|
||
})
|
||
return null
|
||
}
|
||
|
||
const billedOverageThisPeriod = toNumber(toDecimal(stats.billedOverageThisPeriod))
|
||
const unbilledOverage = Math.max(0, currentOverage - billedOverageThisPeriod)
|
||
|
||
logger.debug('Threshold billing check', {
|
||
userId,
|
||
plan: userSubscription.plan,
|
||
currentOverage,
|
||
billedOverageThisPeriod,
|
||
unbilledOverage,
|
||
threshold,
|
||
})
|
||
|
||
if (unbilledOverage < threshold) {
|
||
return null
|
||
}
|
||
|
||
// Apply credits to reduce the amount to bill (use stats from locked row)
|
||
let amountToBill = unbilledOverage
|
||
let creditsApplied = 0
|
||
const creditBalance = toNumber(toDecimal(stats.creditBalance))
|
||
|
||
if (creditBalance > 0) {
|
||
creditsApplied = Math.min(creditBalance, amountToBill)
|
||
await tx
|
||
.update(userStats)
|
||
.set({
|
||
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`,
|
||
})
|
||
.where(eq(userStats.userId, userId))
|
||
amountToBill = amountToBill - creditsApplied
|
||
|
||
logger.info('Applied credits to reduce threshold overage', {
|
||
userId,
|
||
creditBalance,
|
||
creditsApplied,
|
||
remainingToBill: amountToBill,
|
||
})
|
||
}
|
||
|
||
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
|
||
if (amountToBill <= 0) {
|
||
await tx
|
||
.update(userStats)
|
||
.set({
|
||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||
})
|
||
.where(eq(userStats.userId, userId))
|
||
|
||
logger.info('Credits fully covered threshold overage', {
|
||
userId,
|
||
creditsApplied,
|
||
unbilledOverage,
|
||
})
|
||
return { amount: unbilledOverage, creditsApplied, settledVia: 'credits' }
|
||
}
|
||
|
||
const amountCents = Math.round(amountToBill * 100)
|
||
|
||
await tx
|
||
.update(userStats)
|
||
.set({
|
||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||
})
|
||
.where(eq(userStats.userId, userId))
|
||
|
||
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
|
||
customerId,
|
||
stripeSubscriptionId,
|
||
amountCents,
|
||
description: `Threshold overage billing – ${billingPeriod}`,
|
||
itemDescription: `Usage overage ($${amountToBill.toFixed(2)})`,
|
||
billingPeriod,
|
||
invoiceIdemKeyStem: `threshold-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
|
||
itemIdemKeyStem: `threshold-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
|
||
metadata: {
|
||
type: 'overage_threshold_billing',
|
||
userId,
|
||
subscriptionId: stripeSubscriptionId,
|
||
billingPeriod,
|
||
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
|
||
},
|
||
})
|
||
|
||
logger.info('Queued threshold overage invoice for Stripe', {
|
||
userId,
|
||
plan: userSubscription.plan,
|
||
amountToBill,
|
||
billingPeriod,
|
||
creditsApplied,
|
||
totalProcessed: unbilledOverage,
|
||
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
|
||
})
|
||
|
||
return { amount: amountToBill, creditsApplied, settledVia: 'stripe' }
|
||
}
|
||
)
|
||
|
||
if (billedResult) {
|
||
const { amount, creditsApplied, settledVia } = billedResult
|
||
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
|
||
recordAudit({
|
||
actorId: userId,
|
||
action: AuditAction.OVERAGE_BILLED,
|
||
resourceType: AuditResourceType.BILLING,
|
||
resourceId: userSubscription.id,
|
||
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for user ${userId}`,
|
||
metadata: {
|
||
entityType: 'user',
|
||
referenceId: userId,
|
||
plan: userSubscription.plan,
|
||
amount,
|
||
currency: 'usd',
|
||
creditsApplied,
|
||
settledVia,
|
||
billingPeriod,
|
||
},
|
||
})
|
||
captureServerEvent(userId, 'overage_billed', {
|
||
amount,
|
||
currency: 'usd',
|
||
entity_type: 'user',
|
||
reference_id: userId,
|
||
settled_via: settledVia,
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error in threshold billing check', {
|
||
userId,
|
||
error,
|
||
})
|
||
}
|
||
}
|
||
|
||
async function checkAndBillOrganizationOverageThreshold(organizationId: string): Promise<void> {
|
||
logger.info('=== ENTERED checkAndBillOrganizationOverageThreshold ===', { organizationId })
|
||
|
||
try {
|
||
const threshold = OVERAGE_THRESHOLD
|
||
|
||
if (await isOrganizationBillingBlocked(organizationId)) {
|
||
logger.debug('Organization billing blocked for threshold billing', { organizationId })
|
||
return
|
||
}
|
||
|
||
logger.debug('Starting organization threshold billing check', { organizationId, threshold })
|
||
|
||
const orgSubscription = await getOrganizationSubscriptionUsable(organizationId)
|
||
|
||
if (!orgSubscription) {
|
||
logger.debug('No active subscription for organization', { organizationId })
|
||
return
|
||
}
|
||
logger.debug('Found organization subscription', {
|
||
organizationId,
|
||
plan: orgSubscription.plan,
|
||
seats: orgSubscription.seats,
|
||
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
|
||
})
|
||
|
||
if (isEnterprise(orgSubscription.plan) || isFree(orgSubscription.plan)) {
|
||
logger.debug('Organization plan not eligible for overage billing, skipping', {
|
||
organizationId,
|
||
plan: orgSubscription.plan,
|
||
})
|
||
return
|
||
}
|
||
|
||
const memberUsageRows = await db
|
||
.select({
|
||
userId: member.userId,
|
||
role: member.role,
|
||
currentPeriodCost: userStats.currentPeriodCost,
|
||
departedMemberUsage: organization.departedMemberUsage,
|
||
})
|
||
.from(member)
|
||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||
.where(eq(member.organizationId, organizationId))
|
||
|
||
logger.debug('Found organization members', {
|
||
organizationId,
|
||
memberCount: memberUsageRows.length,
|
||
members: memberUsageRows.map((m) => ({ userId: m.userId, role: m.role })),
|
||
})
|
||
|
||
if (memberUsageRows.length === 0) {
|
||
logger.warn('No members found for organization', { organizationId })
|
||
return
|
||
}
|
||
|
||
const usageSnapshot = buildOrganizationUsageSnapshot(memberUsageRows)
|
||
if (!usageSnapshot) {
|
||
logger.error(
|
||
'Organization has no owner when running threshold billing — data integrity issue, skipping',
|
||
{ organizationId }
|
||
)
|
||
return
|
||
}
|
||
|
||
logger.debug('Found organization owner, starting transaction', {
|
||
organizationId,
|
||
ownerId: usageSnapshot.ownerId,
|
||
})
|
||
|
||
const ledgerUsage =
|
||
orgSubscription.periodStart && orgSubscription.periodEnd
|
||
? await getBillingPeriodUsageCost(
|
||
{ type: 'organization', id: organizationId },
|
||
{ start: orgSubscription.periodStart, end: orgSubscription.periodEnd }
|
||
)
|
||
: 0
|
||
|
||
const {
|
||
totalOverage: currentOverage,
|
||
baseSubscriptionAmount: basePrice,
|
||
effectiveUsage: effectiveTeamUsage,
|
||
} = await computeOrgOverageAmount({
|
||
plan: orgSubscription.plan,
|
||
seats: orgSubscription.seats ?? null,
|
||
periodStart: orgSubscription.periodStart ?? null,
|
||
periodEnd: orgSubscription.periodEnd ?? null,
|
||
organizationId,
|
||
pooledCurrentPeriodCost: usageSnapshot.pooledCurrentPeriodCost + ledgerUsage,
|
||
departedMemberUsage: usageSnapshot.departedMemberUsage,
|
||
memberIds: usageSnapshot.memberIds,
|
||
})
|
||
|
||
if (currentOverage < threshold) {
|
||
logger.debug('Organization threshold billing check below threshold before locking', {
|
||
organizationId,
|
||
totalTeamUsage:
|
||
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
|
||
ledgerUsage,
|
||
effectiveTeamUsage,
|
||
basePrice,
|
||
currentOverage,
|
||
threshold,
|
||
})
|
||
return
|
||
}
|
||
|
||
// Validate Stripe identifiers BEFORE mutating credits/trackers.
|
||
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
|
||
if (!stripeSubscriptionId) {
|
||
logger.error('No Stripe subscription ID for organization', { organizationId })
|
||
return
|
||
}
|
||
|
||
const customerId = orgSubscription.stripeCustomerId
|
||
if (!customerId) {
|
||
logger.error('No Stripe customer ID for organization', { organizationId })
|
||
return
|
||
}
|
||
|
||
const periodEnd = orgSubscription.periodEnd
|
||
? Math.floor(orgSubscription.periodEnd.getTime() / 1000)
|
||
: Math.floor(Date.now() / 1000)
|
||
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
|
||
const totalOverageCents = Math.round(currentOverage * 100)
|
||
|
||
const orgBilledResult = await db.transaction(
|
||
async (
|
||
tx
|
||
): Promise<{
|
||
amount: number
|
||
creditsApplied: number
|
||
ownerId: string
|
||
settledVia: 'stripe' | 'credits'
|
||
} | null> => {
|
||
await tx.execute(sql.raw(`SET LOCAL lock_timeout = '${BILLING_LOCK_TIMEOUT_MS}ms'`))
|
||
|
||
const lockedOwnerRows = await tx
|
||
.select({ userId: member.userId })
|
||
.from(member)
|
||
.where(and(eq(member.organizationId, organizationId), eq(member.role, 'owner')))
|
||
.for('update')
|
||
.limit(1)
|
||
const lockedOwnerId = lockedOwnerRows[0]?.userId
|
||
if (!lockedOwnerId) {
|
||
logger.error('Organization owner not found after locking organization', {
|
||
organizationId,
|
||
})
|
||
return null
|
||
}
|
||
|
||
const ownerStatsLock = await tx
|
||
.select()
|
||
.from(userStats)
|
||
.where(eq(userStats.userId, lockedOwnerId))
|
||
.for('update')
|
||
.limit(1)
|
||
if (ownerStatsLock.length === 0) {
|
||
logger.error('Owner stats not found', { organizationId, ownerId: lockedOwnerId })
|
||
return null
|
||
}
|
||
|
||
const orgLock = await tx
|
||
.select()
|
||
.from(organization)
|
||
.where(eq(organization.id, organizationId))
|
||
.for('update')
|
||
.limit(1)
|
||
|
||
if (orgLock.length === 0) {
|
||
logger.error('Organization not found', { organizationId })
|
||
return null
|
||
}
|
||
|
||
const lockedMemberUsageRows = await tx
|
||
.select({
|
||
userId: member.userId,
|
||
role: member.role,
|
||
currentPeriodCost: userStats.currentPeriodCost,
|
||
departedMemberUsage: organization.departedMemberUsage,
|
||
})
|
||
.from(member)
|
||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||
.where(eq(member.organizationId, organizationId))
|
||
|
||
const lockedUsageSnapshot = buildOrganizationUsageSnapshot(lockedMemberUsageRows)
|
||
if (
|
||
!lockedUsageSnapshot ||
|
||
lockedOwnerId !== usageSnapshot.ownerId ||
|
||
!organizationUsageSnapshotMatches(usageSnapshot, lockedUsageSnapshot)
|
||
) {
|
||
logger.debug('Organization usage changed during threshold billing check; retry later', {
|
||
organizationId,
|
||
usageSnapshot,
|
||
lockedUsageSnapshot,
|
||
lockedOwnerId,
|
||
})
|
||
return null
|
||
}
|
||
|
||
const totalBilledOverage = toNumber(toDecimal(ownerStatsLock[0].billedOverageThisPeriod))
|
||
const orgCreditBalance = toNumber(toDecimal(orgLock[0].creditBalance))
|
||
|
||
const unbilledOverage = Math.max(0, currentOverage - totalBilledOverage)
|
||
|
||
logger.debug('Organization threshold billing check', {
|
||
organizationId,
|
||
totalTeamUsage:
|
||
usageSnapshot.pooledCurrentPeriodCost + ledgerUsage + usageSnapshot.departedMemberUsage,
|
||
ledgerUsage,
|
||
effectiveTeamUsage,
|
||
basePrice,
|
||
currentOverage,
|
||
totalBilledOverage,
|
||
unbilledOverage,
|
||
threshold,
|
||
})
|
||
|
||
if (unbilledOverage < threshold) {
|
||
return null
|
||
}
|
||
|
||
let amountToBill = unbilledOverage
|
||
let creditsApplied = 0
|
||
|
||
if (orgCreditBalance > 0) {
|
||
creditsApplied = Math.min(orgCreditBalance, amountToBill)
|
||
await tx
|
||
.update(organization)
|
||
.set({
|
||
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`,
|
||
})
|
||
.where(eq(organization.id, organizationId))
|
||
amountToBill = amountToBill - creditsApplied
|
||
|
||
logger.info('Applied org credits to reduce threshold overage', {
|
||
organizationId,
|
||
creditBalance: orgCreditBalance,
|
||
creditsApplied,
|
||
remainingToBill: amountToBill,
|
||
})
|
||
}
|
||
|
||
// If credits covered everything, bump billed tracker but don't enqueue Stripe invoice.
|
||
if (amountToBill <= 0) {
|
||
await tx
|
||
.update(userStats)
|
||
.set({
|
||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||
})
|
||
.where(eq(userStats.userId, lockedOwnerId))
|
||
|
||
logger.info('Credits fully covered org threshold overage', {
|
||
organizationId,
|
||
creditsApplied,
|
||
unbilledOverage,
|
||
})
|
||
return {
|
||
amount: unbilledOverage,
|
||
creditsApplied,
|
||
ownerId: lockedOwnerId,
|
||
settledVia: 'credits',
|
||
}
|
||
}
|
||
|
||
const amountCents = Math.round(amountToBill * 100)
|
||
|
||
// Bump billed tracker and enqueue Stripe invoice atomically.
|
||
// See user-path above for the full retry-invariant reasoning.
|
||
await tx
|
||
.update(userStats)
|
||
.set({
|
||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||
})
|
||
.where(eq(userStats.userId, lockedOwnerId))
|
||
|
||
await enqueueOutboxEvent(tx, OUTBOX_EVENT_TYPES.STRIPE_THRESHOLD_OVERAGE_INVOICE, {
|
||
customerId,
|
||
stripeSubscriptionId,
|
||
amountCents,
|
||
description: `Team threshold overage billing – ${billingPeriod}`,
|
||
itemDescription: `Team usage overage ($${amountToBill.toFixed(2)})`,
|
||
billingPeriod,
|
||
invoiceIdemKeyStem: `threshold-overage-org-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
|
||
itemIdemKeyStem: `threshold-overage-org-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}:${totalOverageCents}:${amountCents}`,
|
||
metadata: {
|
||
type: 'overage_threshold_billing_org',
|
||
organizationId,
|
||
subscriptionId: stripeSubscriptionId,
|
||
billingPeriod,
|
||
totalOverageAtTimeOfBilling: currentOverage.toFixed(2),
|
||
},
|
||
})
|
||
|
||
logger.info('Queued organization threshold overage invoice for Stripe', {
|
||
organizationId,
|
||
ownerId: lockedOwnerId,
|
||
creditsApplied,
|
||
amountBilled: amountToBill,
|
||
totalProcessed: unbilledOverage,
|
||
billingPeriod,
|
||
})
|
||
|
||
return {
|
||
amount: amountToBill,
|
||
creditsApplied,
|
||
ownerId: lockedOwnerId,
|
||
settledVia: 'stripe',
|
||
}
|
||
}
|
||
)
|
||
|
||
if (orgBilledResult) {
|
||
const { amount, creditsApplied, ownerId, settledVia } = orgBilledResult
|
||
const settledLabel = settledVia === 'credits' ? 'covered by credits' : 'billed'
|
||
recordAudit({
|
||
actorId: ownerId,
|
||
action: AuditAction.OVERAGE_BILLED,
|
||
resourceType: AuditResourceType.BILLING,
|
||
resourceId: orgSubscription.id,
|
||
description: `Overage of $${amount.toFixed(2)} ${settledLabel} for organization ${organizationId}`,
|
||
metadata: {
|
||
entityType: 'organization',
|
||
referenceId: organizationId,
|
||
organizationId,
|
||
plan: orgSubscription.plan,
|
||
amount,
|
||
currency: 'usd',
|
||
creditsApplied,
|
||
settledVia,
|
||
billingPeriod,
|
||
},
|
||
})
|
||
captureServerEvent(ownerId, 'overage_billed', {
|
||
amount,
|
||
currency: 'usd',
|
||
entity_type: 'organization',
|
||
reference_id: organizationId,
|
||
settled_via: settledVia,
|
||
})
|
||
}
|
||
} catch (error) {
|
||
logger.error('Error in organization threshold billing', {
|
||
organizationId,
|
||
error,
|
||
})
|
||
}
|
||
}
|
||
|
||
async function getPersonalUsageSnapshot(userId: string): Promise<PersonalUsageSnapshot | null> {
|
||
const [stats] = await db
|
||
.select({
|
||
currentPeriodCost: userStats.currentPeriodCost,
|
||
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
|
||
proPeriodCostSnapshotAt: userStats.proPeriodCostSnapshotAt,
|
||
lastPeriodCost: userStats.lastPeriodCost,
|
||
})
|
||
.from(userStats)
|
||
.where(eq(userStats.userId, userId))
|
||
.limit(1)
|
||
|
||
return stats ? personalUsageSnapshotFromStats(stats) : null
|
||
}
|
||
|
||
function personalUsageSnapshotFromStats(stats: {
|
||
currentPeriodCost: string | number | null
|
||
proPeriodCostSnapshot: string | number | null
|
||
proPeriodCostSnapshotAt: Date | null
|
||
lastPeriodCost: string | number | null
|
||
}): PersonalUsageSnapshot {
|
||
return {
|
||
currentPeriodCost: toNumber(toDecimal(stats.currentPeriodCost)),
|
||
proPeriodCostSnapshot: toNumber(toDecimal(stats.proPeriodCostSnapshot)),
|
||
proPeriodCostSnapshotAt: stats.proPeriodCostSnapshotAt,
|
||
lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)),
|
||
}
|
||
}
|
||
|
||
function personalUsageSnapshotMatches(
|
||
expected: PersonalUsageSnapshot,
|
||
actual: PersonalUsageSnapshot
|
||
): boolean {
|
||
return (
|
||
Math.abs(expected.currentPeriodCost - actual.currentPeriodCost) <= USAGE_TOTAL_EPSILON &&
|
||
Math.abs(expected.proPeriodCostSnapshot - actual.proPeriodCostSnapshot) <=
|
||
USAGE_TOTAL_EPSILON &&
|
||
Math.abs(expected.lastPeriodCost - actual.lastPeriodCost) <= USAGE_TOTAL_EPSILON &&
|
||
nullableDateTime(expected.proPeriodCostSnapshotAt) ===
|
||
nullableDateTime(actual.proPeriodCostSnapshotAt)
|
||
)
|
||
}
|
||
|
||
function buildOrganizationUsageSnapshot(
|
||
rows: {
|
||
userId: string
|
||
role: string
|
||
currentPeriodCost: string | number | null
|
||
departedMemberUsage: string | number | null
|
||
}[]
|
||
): OrganizationUsageSnapshot | null {
|
||
const owner = rows.find((row) => row.role === 'owner')
|
||
if (!owner) return null
|
||
|
||
const sortedRows = [...rows].sort((a, b) => a.userId.localeCompare(b.userId))
|
||
let pooledCurrentPeriodCost = 0
|
||
for (const row of sortedRows) {
|
||
pooledCurrentPeriodCost += toNumber(toDecimal(row.currentPeriodCost))
|
||
}
|
||
|
||
return {
|
||
memberIds: sortedRows.map((row) => row.userId),
|
||
ownerId: owner.userId,
|
||
memberSignature: sortedRows
|
||
.map(
|
||
(row) =>
|
||
`${row.userId}:${row.role}:${toNumber(toDecimal(row.currentPeriodCost)).toFixed(6)}`
|
||
)
|
||
.join('|'),
|
||
pooledCurrentPeriodCost,
|
||
departedMemberUsage: toNumber(toDecimal(owner.departedMemberUsage)),
|
||
}
|
||
}
|
||
|
||
function organizationUsageSnapshotMatches(
|
||
expected: OrganizationUsageSnapshot,
|
||
actual: OrganizationUsageSnapshot
|
||
): boolean {
|
||
return (
|
||
expected.ownerId === actual.ownerId &&
|
||
expected.memberSignature === actual.memberSignature &&
|
||
Math.abs(expected.departedMemberUsage - actual.departedMemberUsage) <= USAGE_TOTAL_EPSILON
|
||
)
|
||
}
|
||
|
||
function nullableDateTime(value: Date | null): number | null {
|
||
return value?.getTime() ?? null
|
||
}
|