Files
simstudioai--sim/apps/sim/lib/billing/credits/daily-refresh.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

180 lines
5.4 KiB
TypeScript

/**
* Daily Refresh Credits
*
* Each billing period is divided into 1-day windows starting from `periodStart`.
* Users receive `planDollars * DAILY_REFRESH_RATE` in "included" usage per day.
* Usage within that allowance does not count toward the plan limit (use-it-or-lose-it).
*
* The total refresh consumed in a period is:
* SUM( MIN(day_usage, daily_refresh_amount) ) for each day
*
* This is subtracted from `currentPeriodCost` to derive "effective billable usage".
*/
import { db } from '@sim/db'
import { member, usageLog, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, gte, inArray, lt, or, sql, sum } from 'drizzle-orm'
import { DAILY_REFRESH_RATE } from '@/lib/billing/constants'
import type { DbClient } from '@/lib/db/types'
const logger = createLogger('DailyRefresh')
const MS_PER_DAY = 86_400_000
/**
* Optional per-user date window. `usageLog` rows outside
* `[userStart, userEnd)` are excluded from that user's contribution.
* Used to slice refresh around a mid-cycle org join so pre-join and
* post-join refresh are billed by the right subscription.
*/
export interface PerUserBounds {
userStart?: Date | null
userEnd?: Date | null
}
/**
* Compute the total daily refresh credits consumed in the current billing period
* using a single aggregating SQL query grouped by day offset.
*
* For each day from `periodStart`:
* consumed_today = MIN(actual_usage_today, daily_refresh_dollars)
*
* @returns Total dollars of refresh consumed across all days (to subtract from usage)
*/
export async function computeDailyRefreshConsumed(
params: {
userIds: string[]
periodStart: Date
periodEnd?: Date | null
planDollars: number
seats?: number
userBounds?: Record<string, PerUserBounds>
billingEntity?: { type: 'user' | 'organization'; id: string }
},
executor: DbClient = db
): Promise<number> {
const {
userIds,
periodStart,
periodEnd,
planDollars,
seats = 1,
userBounds,
billingEntity,
} = params
if (planDollars <= 0 || userIds.length === 0) return 0
const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats
const now = new Date()
const cap = periodEnd && periodEnd < now ? periodEnd : now
if (cap <= periodStart) return 0
const dayCount = Math.ceil((cap.getTime() - periodStart.getTime()) / MS_PER_DAY)
if (dayCount <= 0) return 0
const unboundedUsers = userBounds ? userIds.filter((id) => !(id in userBounds)) : userIds
const billingEntityFilter = billingEntity
? and(
eq(usageLog.billingEntityType, billingEntity.type),
eq(usageLog.billingEntityId, billingEntity.id),
eq(usageLog.billingPeriodStart, periodStart)
)
: undefined
const boundedClauses = userBounds
? Object.entries(userBounds).flatMap(([userId, bounds]) => {
if (!userIds.includes(userId)) return []
const effectiveStart =
bounds.userStart && bounds.userStart > periodStart ? bounds.userStart : periodStart
const effectiveEnd = bounds.userEnd && bounds.userEnd < cap ? bounds.userEnd : cap
if (effectiveEnd <= effectiveStart) return []
return [
and(
eq(usageLog.userId, userId),
billingEntityFilter,
gte(usageLog.createdAt, effectiveStart),
lt(usageLog.createdAt, effectiveEnd)
),
]
})
: []
const rowFilters =
unboundedUsers.length > 0
? [
and(
inArray(usageLog.userId, unboundedUsers),
billingEntityFilter,
gte(usageLog.createdAt, periodStart),
lt(usageLog.createdAt, cap)
),
...boundedClauses,
]
: boundedClauses
if (rowFilters.length === 0) return 0
const rows = await executor
.select({
dayIndex:
sql<number>`FLOOR((EXTRACT(EPOCH FROM ${usageLog.createdAt}) - ${Math.floor(periodStart.getTime() / 1000)}) / 86400)`.as(
'day_index'
),
dayTotal: sum(usageLog.cost).as('day_total'),
})
.from(usageLog)
.where(rowFilters.length === 1 ? rowFilters[0] : or(...rowFilters))
.groupBy(sql`day_index`)
let totalConsumed = 0
for (const row of rows) {
const dayUsage = Number.parseFloat(row.dayTotal ?? '0')
totalConsumed += Math.min(dayUsage, dailyRefreshDollars)
}
logger.debug('Daily refresh computed', {
userCount: userIds.length,
periodStart: periodStart.toISOString(),
days: dayCount,
dailyRefreshDollars,
totalConsumed,
hasUserBounds: Boolean(userBounds),
})
return totalConsumed
}
/**
* Get the daily refresh allowance in dollars for a plan.
*/
export function getDailyRefreshDollars(planDollars: number): number {
return planDollars * DAILY_REFRESH_RATE
}
export async function getOrgMemberRefreshBounds(
organizationId: string,
periodStart: Date,
executor: DbClient = db
): Promise<Record<string, { userStart: Date }>> {
const rows = await executor
.select({
userId: member.userId,
snapshotAt: userStats.proPeriodCostSnapshotAt,
})
.from(member)
.leftJoin(userStats, eq(member.userId, userStats.userId))
.where(eq(member.organizationId, organizationId))
const bounds: Record<string, { userStart: Date }> = {}
for (const row of rows) {
if (row.snapshotAt && row.snapshotAt > periodStart) {
bounds[row.userId] = { userStart: row.snapshotAt }
}
}
return bounds
}