From 92e0185529a04e89d459fbfb4cb724e79f4ebea7 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Dec 2025 01:57:25 -0800 Subject: [PATCH 1/4] improvement(billing): migrate to decimaljs from number.parseFloat --- apps/sim/lib/billing/core/billing.ts | 102 ++++++++++++------------ apps/sim/lib/billing/core/usage.ts | 40 +++++----- apps/sim/lib/billing/credits/balance.ts | 31 +++---- apps/sim/lib/billing/utils/decimal.ts | 43 ++++++++++ bun.lock | 1 + package.json | 7 +- 6 files changed, 139 insertions(+), 85 deletions(-) create mode 100644 apps/sim/lib/billing/utils/decimal.ts diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 5feac6bab6..8b90982800 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -5,6 +5,7 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getUserUsageData } from '@/lib/billing/core/usage' import { getCreditBalance } from '@/lib/billing/credits/balance' import { getFreeTierLimit, getPlanPricing } from '@/lib/billing/subscriptions/utils' +import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' export { getPlanPricing } @@ -99,7 +100,7 @@ export async function calculateSubscriptionOverage(sub: { return 0 } - let totalOverage = 0 + let totalOverageDecimal = new Decimal(0) if (sub.plan === 'team') { const members = await db @@ -107,10 +108,10 @@ export async function calculateSubscriptionOverage(sub: { .from(member) .where(eq(member.organizationId, sub.referenceId)) - let totalTeamUsage = 0 + let totalTeamUsageDecimal = new Decimal(0) for (const m of members) { const usage = await getUserUsageData(m.userId) - totalTeamUsage += usage.currentUsage + totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(usage.currentUsage)) } const orgData = await db @@ -119,28 +120,29 @@ export async function calculateSubscriptionOverage(sub: { .where(eq(organization.id, sub.referenceId)) .limit(1) - const departedUsage = - orgData.length > 0 && orgData[0].departedMemberUsage - ? Number.parseFloat(orgData[0].departedMemberUsage) - : 0 + const departedUsageDecimal = + orgData.length > 0 ? toDecimal(orgData[0].departedMemberUsage) : new Decimal(0) - const totalUsageWithDeparted = totalTeamUsage + departedUsage + const totalUsageWithDepartedDecimal = totalTeamUsageDecimal.plus(departedUsageDecimal) const { basePrice } = getPlanPricing(sub.plan) const baseSubscriptionAmount = (sub.seats ?? 0) * basePrice - totalOverage = Math.max(0, totalUsageWithDeparted - baseSubscriptionAmount) + totalOverageDecimal = Decimal.max( + 0, + totalUsageWithDepartedDecimal.minus(baseSubscriptionAmount) + ) logger.info('Calculated team overage', { subscriptionId: sub.id, - currentMemberUsage: totalTeamUsage, - departedMemberUsage: departedUsage, - totalUsage: totalUsageWithDeparted, + currentMemberUsage: toNumber(totalTeamUsageDecimal), + departedMemberUsage: toNumber(departedUsageDecimal), + totalUsage: toNumber(totalUsageWithDepartedDecimal), baseSubscriptionAmount, - totalOverage, + totalOverage: toNumber(totalOverageDecimal), }) } else if (sub.plan === 'pro') { // Pro plan: include snapshot if user joined a team const usage = await getUserUsageData(sub.referenceId) - let totalProUsage = usage.currentUsage + let totalProUsageDecimal = toDecimal(usage.currentUsage) // Add any snapshotted Pro usage (from when they joined a team) const userStatsRows = await db @@ -150,41 +152,41 @@ export async function calculateSubscriptionOverage(sub: { .limit(1) if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) { - const snapshotUsage = Number.parseFloat(userStatsRows[0].proPeriodCostSnapshot.toString()) - totalProUsage += snapshotUsage + const snapshotUsageDecimal = toDecimal(userStatsRows[0].proPeriodCostSnapshot) + totalProUsageDecimal = totalProUsageDecimal.plus(snapshotUsageDecimal) logger.info('Including snapshotted Pro usage in overage calculation', { userId: sub.referenceId, currentUsage: usage.currentUsage, - snapshotUsage, - totalProUsage, + snapshotUsage: toNumber(snapshotUsageDecimal), + totalProUsage: toNumber(totalProUsageDecimal), }) } const { basePrice } = getPlanPricing(sub.plan) - totalOverage = Math.max(0, totalProUsage - basePrice) + totalOverageDecimal = Decimal.max(0, totalProUsageDecimal.minus(basePrice)) logger.info('Calculated pro overage', { subscriptionId: sub.id, - totalProUsage, + totalProUsage: toNumber(totalProUsageDecimal), basePrice, - totalOverage, + totalOverage: toNumber(totalOverageDecimal), }) } else { // Free plan or unknown plan type const usage = await getUserUsageData(sub.referenceId) const { basePrice } = getPlanPricing(sub.plan || 'free') - totalOverage = Math.max(0, usage.currentUsage - basePrice) + totalOverageDecimal = Decimal.max(0, toDecimal(usage.currentUsage).minus(basePrice)) logger.info('Calculated overage for plan', { subscriptionId: sub.id, plan: sub.plan || 'free', usage: usage.currentUsage, basePrice, - totalOverage, + totalOverage: toNumber(totalOverageDecimal), }) } - return totalOverage + return toNumber(totalOverageDecimal) } /** @@ -272,14 +274,16 @@ export async function getSimplifiedBillingSummary( const licensedSeats = subscription.seats ?? 0 const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription - let totalCurrentUsage = 0 - let totalCopilotCost = 0 - let totalLastPeriodCopilotCost = 0 + let totalCurrentUsageDecimal = new Decimal(0) + let totalCopilotCostDecimal = new Decimal(0) + let totalLastPeriodCopilotCostDecimal = new Decimal(0) // Calculate total team usage across all members for (const memberInfo of members) { const memberUsageData = await getUserUsageData(memberInfo.userId) - totalCurrentUsage += memberUsageData.currentUsage + totalCurrentUsageDecimal = totalCurrentUsageDecimal.plus( + toDecimal(memberUsageData.currentUsage) + ) // Fetch copilot cost for this member const memberStats = await db @@ -292,15 +296,19 @@ export async function getSimplifiedBillingSummary( .limit(1) if (memberStats.length > 0) { - totalCopilotCost += Number.parseFloat( - memberStats[0].currentPeriodCopilotCost?.toString() || '0' + totalCopilotCostDecimal = totalCopilotCostDecimal.plus( + toDecimal(memberStats[0].currentPeriodCopilotCost) ) - totalLastPeriodCopilotCost += Number.parseFloat( - memberStats[0].lastPeriodCopilotCost?.toString() || '0' + totalLastPeriodCopilotCostDecimal = totalLastPeriodCopilotCostDecimal.plus( + toDecimal(memberStats[0].lastPeriodCopilotCost) ) } } + const totalCurrentUsage = toNumber(totalCurrentUsageDecimal) + const totalCopilotCost = toNumber(totalCopilotCostDecimal) + const totalLastPeriodCopilotCost = toNumber(totalLastPeriodCopilotCostDecimal) + // Calculate team-level overage: total usage beyond what was already paid to Stripe const totalOverage = Math.max(0, totalCurrentUsage - totalBasePrice) @@ -380,14 +388,10 @@ export async function getSimplifiedBillingSummary( .limit(1) const copilotCost = - userStatsRows.length > 0 - ? Number.parseFloat(userStatsRows[0].currentPeriodCopilotCost?.toString() || '0') - : 0 + userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].currentPeriodCopilotCost)) : 0 const lastPeriodCopilotCost = - userStatsRows.length > 0 - ? Number.parseFloat(userStatsRows[0].lastPeriodCopilotCost?.toString() || '0') - : 0 + userStatsRows.length > 0 ? toNumber(toDecimal(userStatsRows[0].lastPeriodCopilotCost)) : 0 // For team and enterprise plans, calculate total team usage instead of individual usage let currentUsage = usageData.currentUsage @@ -400,12 +404,12 @@ export async function getSimplifiedBillingSummary( .from(member) .where(eq(member.organizationId, subscription.referenceId)) - let totalTeamUsage = 0 - let totalTeamCopilotCost = 0 - let totalTeamLastPeriodCopilotCost = 0 + let totalTeamUsageDecimal = new Decimal(0) + let totalTeamCopilotCostDecimal = new Decimal(0) + let totalTeamLastPeriodCopilotCostDecimal = new Decimal(0) for (const teamMember of teamMembers) { const memberUsageData = await getUserUsageData(teamMember.userId) - totalTeamUsage += memberUsageData.currentUsage + totalTeamUsageDecimal = totalTeamUsageDecimal.plus(toDecimal(memberUsageData.currentUsage)) // Fetch copilot cost for this team member const memberStats = await db @@ -418,17 +422,17 @@ export async function getSimplifiedBillingSummary( .limit(1) if (memberStats.length > 0) { - totalTeamCopilotCost += Number.parseFloat( - memberStats[0].currentPeriodCopilotCost?.toString() || '0' + totalTeamCopilotCostDecimal = totalTeamCopilotCostDecimal.plus( + toDecimal(memberStats[0].currentPeriodCopilotCost) ) - totalTeamLastPeriodCopilotCost += Number.parseFloat( - memberStats[0].lastPeriodCopilotCost?.toString() || '0' + totalTeamLastPeriodCopilotCostDecimal = totalTeamLastPeriodCopilotCostDecimal.plus( + toDecimal(memberStats[0].lastPeriodCopilotCost) ) } } - currentUsage = totalTeamUsage - totalCopilotCost = totalTeamCopilotCost - totalLastPeriodCopilotCost = totalTeamLastPeriodCopilotCost + currentUsage = toNumber(totalTeamUsageDecimal) + totalCopilotCost = toNumber(totalTeamCopilotCostDecimal) + totalLastPeriodCopilotCost = toNumber(totalTeamLastPeriodCopilotCostDecimal) } const overageAmount = Math.max(0, currentUsage - basePrice) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index f32ac38bff..9b1cf83e1e 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -14,6 +14,7 @@ import { getPlanPricing, } from '@/lib/billing/subscriptions/utils' import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types' +import { Decimal, toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' @@ -45,7 +46,7 @@ export async function getOrgUsageLimit( const configured = orgData.length > 0 && orgData[0].orgUsageLimit - ? Number.parseFloat(orgData[0].orgUsageLimit) + ? toNumber(toDecimal(orgData[0].orgUsageLimit)) : null if (plan === 'enterprise') { @@ -111,22 +112,23 @@ export async function getUserUsageData(userId: string): Promise { } const stats = userStatsData[0] - let currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0') + let currentUsageDecimal = toDecimal(stats.currentPeriodCost) // For Pro users, include any snapshotted usage (from when they joined a team) // This ensures they see their total Pro usage in the UI if (subscription && subscription.plan === 'pro' && subscription.referenceId === userId) { - const snapshotUsage = Number.parseFloat(stats.proPeriodCostSnapshot?.toString() ?? '0') - if (snapshotUsage > 0) { - currentUsage += snapshotUsage + const snapshotUsageDecimal = toDecimal(stats.proPeriodCostSnapshot) + if (snapshotUsageDecimal.greaterThan(0)) { + currentUsageDecimal = currentUsageDecimal.plus(snapshotUsageDecimal) logger.info('Including Pro snapshot in usage display', { userId, currentPeriodCost: stats.currentPeriodCost, - proPeriodCostSnapshot: snapshotUsage, - totalUsage: currentUsage, + proPeriodCostSnapshot: toNumber(snapshotUsageDecimal), + totalUsage: toNumber(currentUsageDecimal), }) } } + const currentUsage = toNumber(currentUsageDecimal) // Determine usage limit based on plan type let limit: number @@ -134,7 +136,7 @@ export async function getUserUsageData(userId: string): Promise { if (!subscription || subscription.plan === 'free' || subscription.plan === 'pro') { // Free/Pro: Use individual user limit from userStats limit = stats.currentUsageLimit - ? Number.parseFloat(stats.currentUsageLimit) + ? toNumber(toDecimal(stats.currentUsageLimit)) : getFreeTierLimit() } else { // Team/Enterprise: Use organization limit @@ -163,7 +165,7 @@ export async function getUserUsageData(userId: string): Promise { isExceeded, billingPeriodStart, billingPeriodEnd, - lastPeriodCost: Number.parseFloat(stats.lastPeriodCost?.toString() || '0'), + lastPeriodCost: toNumber(toDecimal(stats.lastPeriodCost)), } } catch (error) { logger.error('Failed to get user usage data', { userId, error }) @@ -195,7 +197,7 @@ export async function getUserUsageLimitInfo(userId: string): Promise { ) } - return Number.parseFloat(userStatsQuery[0].currentUsageLimit) + return toNumber(toDecimal(userStatsQuery[0].currentUsageLimit)) } // Team/Enterprise: Verify org exists then use organization limit const orgExists = await db @@ -438,7 +440,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise 0 ? Number.parseFloat(orgRows[0].creditBalance || '0') : 0, + balance: orgRows.length > 0 ? toNumber(toDecimal(orgRows[0].creditBalance)) : 0, entityType: 'organization', entityId: subscription.referenceId, } @@ -36,7 +37,7 @@ export async function getCreditBalance(userId: string): Promise 0 ? Number.parseFloat(userRows[0].creditBalance || '0') : 0, + balance: userRows.length > 0 ? toNumber(toDecimal(userRows[0].creditBalance)) : 0, entityType: 'user', entityId: userId, } @@ -92,20 +93,21 @@ export interface DeductResult { } async function atomicDeductUserCredits(userId: string, cost: number): Promise { - const costStr = cost.toFixed(6) + const costDecimal = toDecimal(cost) + const costStr = toFixedString(costDecimal) // Use raw SQL with CTE to capture old balance before update const result = await db.execute<{ old_balance: string; new_balance: string }>(sql` WITH old_balance AS ( SELECT credit_balance FROM user_stats WHERE user_id = ${userId} ) - UPDATE user_stats - SET credit_balance = CASE + UPDATE user_stats + SET credit_balance = CASE WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal ELSE 0 END WHERE user_id = ${userId} AND credit_balance >= 0 - RETURNING + RETURNING (SELECT credit_balance FROM old_balance) as old_balance, credit_balance as new_balance `) @@ -113,25 +115,26 @@ async function atomicDeductUserCredits(userId: string, cost: number): Promise { - const costStr = cost.toFixed(6) + const costDecimal = toDecimal(cost) + const costStr = toFixedString(costDecimal) // Use raw SQL with CTE to capture old balance before update const result = await db.execute<{ old_balance: string; new_balance: string }>(sql` WITH old_balance AS ( SELECT credit_balance FROM organization WHERE id = ${orgId} ) - UPDATE organization - SET credit_balance = CASE + UPDATE organization + SET credit_balance = CASE WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal ELSE 0 END WHERE id = ${orgId} AND credit_balance >= 0 - RETURNING + RETURNING (SELECT credit_balance FROM old_balance) as old_balance, credit_balance as new_balance `) @@ -139,8 +142,8 @@ async function atomicDeductOrgCredits(orgId: string, cost: number): Promise { diff --git a/apps/sim/lib/billing/utils/decimal.ts b/apps/sim/lib/billing/utils/decimal.ts new file mode 100644 index 0000000000..994d7e26a7 --- /dev/null +++ b/apps/sim/lib/billing/utils/decimal.ts @@ -0,0 +1,43 @@ +import Decimal from 'decimal.js' + +/** + * Configure Decimal.js for billing precision. + * 20 significant digits is more than enough for currency calculations. + */ +Decimal.set({ precision: 20, rounding: Decimal.ROUND_HALF_UP }) + +/** + * Parse a value to Decimal for precise billing calculations. + * Handles null, undefined, empty strings, and number/string inputs. + */ +export function toDecimal(value: string | number | null | undefined): Decimal { + if (value === null || value === undefined || value === '') { + return new Decimal(0) + } + return new Decimal(value) +} + +/** + * Convert Decimal back to number for storage/API responses. + * Use this at the final step when returning values. + */ +export function toNumber(value: Decimal): number { + return value.toNumber() +} + +/** + * Sum an array of values with decimal precision. + */ +export function sumDecimals(values: Array): Decimal { + return values.reduce((acc: Decimal, val) => acc.plus(toDecimal(val)), new Decimal(0)) +} + +/** + * Format a Decimal to a fixed string for database storage. + * Uses 6 decimal places which matches current DB precision. + */ +export function toFixedString(value: Decimal, decimalPlaces = 6): string { + return value.toFixed(decimalPlaces) +} + +export { Decimal } diff --git a/bun.lock b/bun.lock index 11ea7e3a66..f407431fa8 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@tanstack/react-query-devtools": "5.90.2", "@types/fluent-ffmpeg": "2.1.28", "cronstrue": "3.3.0", + "decimal.js": "10.6.0", "drizzle-orm": "^0.44.5", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", diff --git a/package.json b/package.json index ef795e12ed..91a28bef20 100644 --- a/package.json +++ b/package.json @@ -35,27 +35,28 @@ }, "dependencies": { "@linear/sdk": "40.0.0", - "next-runtime-env": "3.3.0", "@modelcontextprotocol/sdk": "1.20.2", "@t3-oss/env-nextjs": "0.13.4", - "zod": "^3.24.2", "@tanstack/react-query": "5.90.8", "@tanstack/react-query-devtools": "5.90.2", "@types/fluent-ffmpeg": "2.1.28", "cronstrue": "3.3.0", + "decimal.js": "10.6.0", "drizzle-orm": "^0.44.5", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", "isolated-vm": "6.0.2", "mongodb": "6.19.0", "neo4j-driver": "6.0.1", + "next-runtime-env": "3.3.0", "nodemailer": "7.0.11", "onedollarstats": "0.0.10", "postgres": "^3.4.5", "remark-gfm": "4.0.1", "rss-parser": "3.13.0", "socket.io-client": "4.8.1", - "twilio": "5.9.0" + "twilio": "5.9.0", + "zod": "^3.24.2" }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", From c404c010341b7f7f2a47977a61c1a3701955090c Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Dec 2025 12:01:02 -0800 Subject: [PATCH 2/4] ack PR comments --- apps/sim/lib/billing/core/billing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 8b90982800..8dba7e15e4 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -310,7 +310,7 @@ export async function getSimplifiedBillingSummary( const totalLastPeriodCopilotCost = toNumber(totalLastPeriodCopilotCostDecimal) // Calculate team-level overage: total usage beyond what was already paid to Stripe - const totalOverage = Math.max(0, totalCurrentUsage - totalBasePrice) + const totalOverage = toNumber(Decimal.max(0, totalCurrentUsageDecimal.minus(totalBasePrice))) // Get user's personal limits for warnings const percentUsed = @@ -435,7 +435,7 @@ export async function getSimplifiedBillingSummary( totalLastPeriodCopilotCost = toNumber(totalTeamLastPeriodCopilotCostDecimal) } - const overageAmount = Math.max(0, currentUsage - basePrice) + const overageAmount = toNumber(Decimal.max(0, toDecimal(currentUsage).minus(basePrice))) const percentUsed = usageData.limit > 0 ? (currentUsage / usageData.limit) * 100 : 0 // Calculate days remaining in billing period From b13ae619c6eae8f5ddc78028178898de96f8df88 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Dec 2025 12:22:16 -0800 Subject: [PATCH 3/4] ack pr comment --- apps/sim/lib/billing/utils/decimal.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/sim/lib/billing/utils/decimal.ts b/apps/sim/lib/billing/utils/decimal.ts index 994d7e26a7..13249ec47f 100644 --- a/apps/sim/lib/billing/utils/decimal.ts +++ b/apps/sim/lib/billing/utils/decimal.ts @@ -25,13 +25,6 @@ export function toNumber(value: Decimal): number { return value.toNumber() } -/** - * Sum an array of values with decimal precision. - */ -export function sumDecimals(values: Array): Decimal { - return values.reduce((acc: Decimal, val) => acc.plus(toDecimal(val)), new Decimal(0)) -} - /** * Format a Decimal to a fixed string for database storage. * Uses 6 decimal places which matches current DB precision. From 14790233f6e0dec6752068baf729697a590ec365 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 26 Dec 2025 12:29:36 -0800 Subject: [PATCH 4/4] consistency --- apps/sim/lib/billing/credits/balance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts index c6d2021f23..7ce8b6a65e 100644 --- a/apps/sim/lib/billing/credits/balance.ts +++ b/apps/sim/lib/billing/credits/balance.ts @@ -3,7 +3,7 @@ import { member, organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal' +import { Decimal, toDecimal, toFixedString, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('CreditBalance') @@ -162,7 +162,7 @@ export async function deductFromCredits(userId: string, cost: number): Promise 0) { logger.info('Deducted credits atomically', {