simstudioai--sim
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 行
24 KiB
TypeScript
728 行
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
|
|
}
|