From 9a0bc96b86bdc74944bbdd299e53562ac0aed1f6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 26 Aug 2026 12:04:37 -0700 Subject: [PATCH 1/2] improvement(billing): replace daily refresh credits with weekly refresh --- apps/docs/content/docs/en/platform/costs.mdx | 24 +-- .../settings/components/billing/billing.tsx | 15 +- .../comparison-table/comparison-data.ts | 17 +- .../components/plan-card/plan-card.tsx | 2 +- .../[workspaceId]/upgrade/plan-configs.ts | 6 +- .../components/emails/billing/constants.ts | 2 +- .../index.mdx | 6 +- .../calculations/usage-monitor.test.ts | 28 +-- .../lib/billing/calculations/usage-monitor.ts | 26 +-- apps/sim/lib/billing/constants.ts | 29 +-- apps/sim/lib/billing/core/billing.test.ts | 18 +- apps/sim/lib/billing/core/billing.ts | 50 +++--- apps/sim/lib/billing/core/organization.ts | 12 +- apps/sim/lib/billing/core/usage.test.ts | 4 +- apps/sim/lib/billing/core/usage.ts | 31 ++-- ...refresh.test.ts => weekly-refresh.test.ts} | 170 ++++++++++-------- .../{daily-refresh.ts => weekly-refresh.ts} | 93 +++++----- apps/sim/lib/billing/cycle-close.test.ts | 26 +-- apps/sim/lib/billing/cycle-close.ts | 12 +- apps/sim/lib/billing/plan-helpers.ts | 21 +++ apps/sim/lib/compare/data/sim.ts | 6 +- packages/db/schema.ts | 2 +- 22 files changed, 337 insertions(+), 263 deletions(-) rename apps/sim/lib/billing/credits/{daily-refresh.test.ts => weekly-refresh.test.ts} (56%) rename apps/sim/lib/billing/credits/{daily-refresh.ts => weekly-refresh.ts} (66%) diff --git a/apps/docs/content/docs/en/platform/costs.mdx b/apps/docs/content/docs/en/platform/costs.mdx index b9514b08371..4762b98c6e7 100644 --- a/apps/docs/content/docs/en/platform/costs.mdx +++ b/apps/docs/content/docs/en/platform/costs.mdx @@ -331,27 +331,27 @@ Each voice session is billed when it starts. In deployed chat voice mode, each c Sim has two paid plan tiers - **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization. -| Plan | Price | Credits Included | Daily Refresh | -|------|-------|------------------|---------------| +| Plan | Price | Credits Included | Weekly Refresh | +|------|-------|------------------|----------------| | **Community** | $0 | 1,000 (one-time) | - | -| **Pro** | $25/mo | 6,000/mo | +50/day | -| **Max** | $100/mo | 25,000/mo | +200/day | +| **Pro** | $25/mo | 6,000/mo | +2,000/week | +| **Max** | $100/mo | 25,000/mo | +4,000/week | | **Enterprise** | Custom | Custom | - | To use Pro or Max with a team, select **Get For Team** in subscription settings and choose the tier and number of seats. Credits are pooled across the organization at the per-seat rate (e.g. Max for Teams with 3 seats = 75,000 credits/mo pooled). Internal organization members use seats and contribute to the team's pooled credit allocation. External workspace members do not join your organization, do not appear in the organization roster, and do not count toward your seat total. -### Daily Refresh Credits +### Weekly Refresh Credits -Paid plans include a small daily credit allowance that does not count toward your plan limit. Each day, usage up to the daily refresh amount is excluded from billable usage. This allowance resets every 24 hours and does not carry over - use it or lose it. +Paid plans include a weekly credit allowance that does not count toward your plan limit. Each week, usage up to the weekly refresh amount is excluded from billable usage. This allowance resets every 7 days from your billing period start and does not carry over - use it or lose it. -| Plan | Daily Refresh | -|------|---------------| -| **Pro** | 50 credits/day ($0.25) | -| **Max** | 200 credits/day ($1.00) | +| Plan | Weekly Refresh | +|------|----------------| +| **Pro** | 2,000 credits/week ($10.00) | +| **Max** | 4,000 credits/week ($20.00) | -For team plans, the daily refresh scales with seats (e.g. Max for Teams with 3 seats = 600 credits/day). +For team plans, the weekly refresh scales with seats (e.g. Max for Teams with 3 seats = 12,000 credits/week). ### Annual Billing @@ -573,7 +573,7 @@ import { FAQ } from '@/components/ui/faq' ({ id: invoice.id, date: formatDate(new Date(invoice.created * 1000)), @@ -579,6 +583,15 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps )} + {subscription.isPaid && weeklyRefreshDollars > 0 && ( +
+ Weekly refresh + + +{formatCreditCost(weeklyRefreshDollars)} + +
+ )} +
Payment method credits.toLocaleString('en-US') -/** Daily refresh credits for a plan: 1% of plan dollars/day, in credits. */ -const dailyRefreshCredits = (dollars: number): number => - Math.round(dollars * DAILY_REFRESH_RATE * CREDITS_PER_DOLLAR) - /** A brand icon rendered in a cell instead of a check/em-dash/text. */ export interface CellIcon { /** Icon identifier resolved to a component by the table renderer. */ @@ -94,11 +85,11 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [ ], }, { - label: 'Daily refresh', + label: 'Weekly refresh', values: [ false, - `+${formatCredits(dailyRefreshCredits(PRO_TIER.dollars))}`, - `+${formatCredits(dailyRefreshCredits(MAX_TIER.dollars))}`, + `+${formatCredits(PRO_TIER.weeklyRefreshCredits)}`, + `+${formatCredits(MAX_TIER.weeklyRefreshCredits)}`, 'Custom', ], }, diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx index 686b03365fb..fdd96328c17 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/components/plan-card/plan-card.tsx @@ -22,7 +22,7 @@ export interface UpgradePlanCardProps { */ credits?: string /** - * Daily refresh allocation shown below the credit amount, e.g. `"+50/day refresh"`. + * Weekly refresh allocation shown below the credit amount, e.g. `"+2,000/week refresh"`. * Only rendered when {@link UpgradePlanCardProps.credits} is also set. */ refresh?: string diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts index 1a39eb2e6fd..03b87e22618 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts @@ -7,18 +7,18 @@ import { DEFAULT_BILLING_CONCURRENCY_LIMITS } from '@/lib/billing/concurrency-de export interface PlanCredits { /** Formatted credits string, e.g. `"6,000 credits/mo"`. */ credits: string - /** Formatted daily-refresh string, e.g. `"+50/day refresh"`. */ + /** Formatted weekly-refresh string, e.g. `"+2,000/week refresh"`. */ refresh: string } export const PRO_PLAN_CREDITS: PlanCredits = { credits: '6,000 credits/mo', - refresh: '+50/day refresh', + refresh: '+2,000/week refresh', } export const MAX_PLAN_CREDITS: PlanCredits = { credits: '25,000 credits/mo', - refresh: '+200/day refresh', + refresh: '+4,000/week refresh', } export const ENTERPRISE_PLAN_CREDITS: PlanCredits = { diff --git a/apps/sim/components/emails/billing/constants.ts b/apps/sim/components/emails/billing/constants.ts index 7767a4b8d08..e415986a82d 100644 --- a/apps/sim/components/emails/billing/constants.ts +++ b/apps/sim/components/emails/billing/constants.ts @@ -1,7 +1,7 @@ /** Pro plan features shown in billing upgrade emails */ export const proFeatures = [ { label: '6,000 credits/month', desc: 'included' }, - { label: '+50 daily refresh', desc: 'credits per day' }, + { label: '+2,000 weekly refresh', desc: 'credits per week' }, { label: '150 runs/min', desc: 'sync executions' }, { label: '50GB storage', desc: 'for files & assets' }, ] as const diff --git a/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx b/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx index 026983fd126..8af041d6755 100644 --- a/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx +++ b/apps/sim/content/library/best-multi-agent-frameworks-2026/index.mdx @@ -3,7 +3,7 @@ slug: best-multi-agent-frameworks-2026 title: 'Best Multi-Agent Frameworks for Production in 2026' description: 'Compare the best multi-agent frameworks for production in 2026 across orchestration, state, observability, licensing, self-hosting, deployment, and pricing.' date: 2026-08-25 -updated: 2026-08-25 +updated: 2026-08-26 authors: - andrew readingTime: 19 @@ -163,8 +163,8 @@ That shared graph also makes agent handoffs, state changes, and business rules e **As of August 2026, [Sim pricing](https://www.sim.ai/pricing) starts at $0 and uses both per-user plan pricing and usage credits.** - **Free:** $0 with 1,000 monthly credits. -- **Pro:** $25 per user per month with 6,000 monthly credits and a 50-credit daily refresh. -- **Max:** $100 per user per month with 25,000 monthly credits and a 200-credit daily refresh. +- **Pro:** $25 per user per month with 6,000 monthly credits and a 2,000-credit weekly refresh. +- **Max:** $100 per user per month with 25,000 monthly credits and a 4,000-credit weekly refresh. - **Enterprise:** Custom pricing and custom credits. According to the [Sim cost documentation](https://docs.sim.ai/platform/costs), one credit equals $0.005. Each run includes a base charge of one credit, with hosted model cost converted to credits. Sim-hosted models use a 1.1× multiplier. BYOK lets customers pay model providers directly at provider pricing with no Sim markup. diff --git a/apps/sim/lib/billing/calculations/usage-monitor.test.ts b/apps/sim/lib/billing/calculations/usage-monitor.test.ts index a11905e5980..3fed2e53576 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.test.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.test.ts @@ -10,14 +10,14 @@ const { mockGetOrgMemberUsageLimit, mockGetUserUsageLimit, mockIsOrganizationBillingBlocked, - mockComputeBillingPeriodUsageWithDailyRefresh, + mockComputeBillingPeriodUsageWithWeeklyRefresh, } = vi.hoisted(() => ({ mockGetBillingPeriodUsageCost: vi.fn(), mockGetOrgMemberUsageForBillingPeriod: vi.fn(), mockGetOrgMemberUsageLimit: vi.fn(), mockGetUserUsageLimit: vi.fn(), mockIsOrganizationBillingBlocked: vi.fn(), - mockComputeBillingPeriodUsageWithDailyRefresh: vi.fn(), + mockComputeBillingPeriodUsageWithWeeklyRefresh: vi.fn(), })) vi.mock('@/lib/billing/organizations/member-limits', () => ({ @@ -39,8 +39,8 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost, })) -vi.mock('@/lib/billing/credits/daily-refresh', () => ({ - computeBillingPeriodUsageWithDailyRefresh: mockComputeBillingPeriodUsageWithDailyRefresh, +vi.mock('@/lib/billing/credits/weekly-refresh', () => ({ + computeBillingPeriodUsageWithWeeklyRefresh: mockComputeBillingPeriodUsageWithWeeklyRefresh, })) import { @@ -64,7 +64,7 @@ describe('checkUsageStatus', () => { setEnvFlags({ isHosted: true, isBillingEnabled: true }) mockGetUserUsageLimit.mockResolvedValue(500) mockGetBillingPeriodUsageCost.mockResolvedValue(125) - mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({ + mockComputeBillingPeriodUsageWithWeeklyRefresh.mockResolvedValue({ ledgerUsage: 125, refreshConsumed: 25, }) @@ -121,17 +121,17 @@ describe('checkUsageStatus', () => { scope: 'user', }) - expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({ + expect(mockComputeBillingPeriodUsageWithWeeklyRefresh).toHaveBeenCalledWith({ billingEntity: { type: 'user', id: 'user-1' }, billingPeriod: { start: periodStart, end: periodEnd }, refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, - planDollars: 20, + weeklyRefreshDollars: 10, }) expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled() }) - it('preserves the paid daily-refresh clamp for negative effective usage', async () => { + it('preserves the paid weekly-refresh clamp for negative effective usage', async () => { const periodStart = new Date('2026-06-01T00:00:00.000Z') const periodEnd = new Date('2026-07-01T00:00:00.000Z') const subscription = { @@ -142,7 +142,7 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValueOnce({ + mockComputeBillingPeriodUsageWithWeeklyRefresh.mockResolvedValueOnce({ ledgerUsage: -1, refreshConsumed: 1, }) @@ -173,7 +173,7 @@ describe('checkUsageStatus', () => { { type: 'user', id: 'user-1' }, { start: periodStart, end: periodEnd } ) - expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() + expect(mockComputeBillingPeriodUsageWithWeeklyRefresh).not.toHaveBeenCalled() }) it('preserves negative ledger-only personal usage', async () => { @@ -194,7 +194,7 @@ describe('checkUsageStatus', () => { scope: 'user', }) - expect(mockComputeBillingPeriodUsageWithDailyRefresh).not.toHaveBeenCalled() + expect(mockComputeBillingPeriodUsageWithWeeklyRefresh).not.toHaveBeenCalled() }) it('combines paid organization ledger usage with entity-scoped refresh — no roster read', async () => { @@ -208,7 +208,7 @@ describe('checkUsageStatus', () => { periodStart, periodEnd, } - mockComputeBillingPeriodUsageWithDailyRefresh.mockResolvedValue({ + mockComputeBillingPeriodUsageWithWeeklyRefresh.mockResolvedValue({ ledgerUsage: 100, refreshConsumed: 10, }) @@ -221,7 +221,7 @@ describe('checkUsageStatus', () => { // Refresh is scoped by the entity stamps alone, so departed members' // org-attributed rows participate identically to current members'. - expect(mockComputeBillingPeriodUsageWithDailyRefresh).toHaveBeenCalledWith({ + expect(mockComputeBillingPeriodUsageWithWeeklyRefresh).toHaveBeenCalledWith({ billingEntity: { type: 'organization', id: 'org-1' }, billingPeriod: expect.objectContaining({ start: periodStart, @@ -230,7 +230,7 @@ describe('checkUsageStatus', () => { }), refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, - planDollars: expect.any(Number), + weeklyRefreshDollars: expect.any(Number), seats: 2, }) expect(mockGetBillingPeriodUsageCost).not.toHaveBeenCalled() diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index b8387fd28e0..2fab6468c14 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -15,12 +15,12 @@ import { type UsageQueryPeriod, } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { computeBillingPeriodUsageWithDailyRefresh } from '@/lib/billing/credits/daily-refresh' +import { computeBillingPeriodUsageWithWeeklyRefresh } from '@/lib/billing/credits/weekly-refresh' import { getOrgMemberUsageForBillingPeriod, getOrgMemberUsageLimit, } from '@/lib/billing/organizations/member-limits' -import { getPlanTierDollars, isPaid } from '@/lib/billing/plan-helpers' +import { getPlanWeeklyRefreshDollars, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' @@ -61,17 +61,17 @@ async function computePooledOrgUsage( return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars <= 0) { + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(sub.plan) + if (weeklyRefreshDollars <= 0) { return getBillingPeriodUsageCost({ type: 'organization', id: organizationId }, billingPeriod) } - const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithDailyRefresh({ + const { ledgerUsage, refreshConsumed } = await computeBillingPeriodUsageWithWeeklyRefresh({ billingEntity: { type: 'organization', id: organizationId }, billingPeriod, refreshPeriodStart: sub.periodStart, refreshPeriodEnd: sub.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, seats: sub.seats || 1, }) @@ -134,20 +134,20 @@ export async function checkUsageStatus( : defaultBillingPeriod()) let ledgerUsage: number let refreshConsumed = 0 - let appliedDailyRefresh = false + let appliedWeeklyRefresh = false if (sub && isPaid(sub.plan) && sub.periodStart) { - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars > 0) { - const usage = await computeBillingPeriodUsageWithDailyRefresh({ + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(sub.plan) + if (weeklyRefreshDollars > 0) { + const usage = await computeBillingPeriodUsageWithWeeklyRefresh({ billingEntity: { type: 'user', id: userId }, billingPeriod, refreshPeriodStart: sub.periodStart, refreshPeriodEnd: sub.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, }) ledgerUsage = usage.ledgerUsage refreshConsumed = usage.refreshConsumed - appliedDailyRefresh = true + appliedWeeklyRefresh = true } else { ledgerUsage = await getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod) } @@ -155,7 +155,7 @@ export async function checkUsageStatus( ledgerUsage = await getBillingPeriodUsageCost({ type: 'user', id: userId }, billingPeriod) } const usageBeforeRefresh = ledgerUsage - refreshConsumed - const currentUsage = appliedDailyRefresh ? Math.max(0, usageBeforeRefresh) : usageBeforeRefresh + const currentUsage = appliedWeeklyRefresh ? Math.max(0, usageBeforeRefresh) : usageBeforeRefresh return buildUsageData({ currentUsage, limit, scope, organizationId }) } catch (error) { diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts index a3c793e29d8..ebe5e7c17b5 100644 --- a/apps/sim/lib/billing/constants.ts +++ b/apps/sim/lib/billing/constants.ts @@ -40,11 +40,26 @@ export const DEFAULT_OVERAGE_THRESHOLD = 100 export const BILLING_LOCK_TIMEOUT_MS = 5_000 /** - * Available credit tiers. Each tier maps a credit amount to the underlying dollar cost. + * Available credit tiers. Each tier maps a credit amount to the underlying dollar + * cost and carries that tier's fixed weekly refresh allowance. * 1 credit = $0.005, so credits = dollars * 200. + * + * `weeklyRefreshCredits` is a fixed per-tier amount, NOT a rate. Which plans map + * to which allowance (legacy plans, seat scaling, free/enterprise exclusion) is + * owned by `getPlanWeeklyRefreshDollars` in `@/lib/billing/plan-helpers`. */ -const PRO_CREDIT_TIER = { credits: 6000, dollars: 25, name: 'Pro' } as const -const MAX_CREDIT_TIER = { credits: 25000, dollars: 100, name: 'Max' } as const +export const PRO_CREDIT_TIER = { + credits: 6000, + dollars: 25, + weeklyRefreshCredits: 2000, + name: 'Pro', +} as const +export const MAX_CREDIT_TIER = { + credits: 25000, + dollars: 100, + weeklyRefreshCredits: 4000, + name: 'Max', +} as const export const CREDIT_TIERS = [PRO_CREDIT_TIER, MAX_CREDIT_TIER] as const @@ -63,16 +78,10 @@ export const MAX_TIER_CREDITS = MAX_CREDIT_TIER.credits /** * Credits granted per dollar of plan spend. A credit is $0.005, so a dollar - * buys 200 — the conversion behind both free-tier and daily-refresh credits. + * buys 200 — the conversion behind both free-tier and weekly-refresh credits. */ export const CREDITS_PER_DOLLAR = 200 -/** - * Daily refresh rate: 1% of plan cost per day. - * E.g. $25 plan => $0.25/day => 50 credits/day included usage. - */ -export const DAILY_REFRESH_RATE = 0.01 - /** * Annual subscribers pay 15% less than the equivalent monthly plan * but receive the same included credits. The Stripe annual price is diff --git a/apps/sim/lib/billing/core/billing.test.ts b/apps/sim/lib/billing/core/billing.test.ts index 4ee6c354483..e735a1df509 100644 --- a/apps/sim/lib/billing/core/billing.test.ts +++ b/apps/sim/lib/billing/core/billing.test.ts @@ -5,7 +5,7 @@ import { dbChainMock, dbChainMockFns, queueTableRows, schemaMock } from '@sim/te import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockComputeDailyRefreshConsumed, + mockComputeWeeklyRefreshConsumed, mockEnsureUserStatsExists, mockGetBillingPeriodUsageCost, mockGetBillingPeriodUsageCostWithSourceSubset, @@ -13,7 +13,7 @@ const { mockGetHighestPrioritySubscription, mockResolveBillingInterval, } = vi.hoisted(() => ({ - mockComputeDailyRefreshConsumed: vi.fn(), + mockComputeWeeklyRefreshConsumed: vi.fn(), mockEnsureUserStatsExists: vi.fn(), mockGetBillingPeriodUsageCost: vi.fn(), mockGetBillingPeriodUsageCostWithSourceSubset: vi.fn(), @@ -40,8 +40,8 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingPeriodUsageCostWithSourceSubset: mockGetBillingPeriodUsageCostWithSourceSubset, })) -vi.mock('@/lib/billing/credits/daily-refresh', () => ({ - computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed, +vi.mock('@/lib/billing/credits/weekly-refresh', () => ({ + computeWeeklyRefreshConsumed: mockComputeWeeklyRefreshConsumed, })) import { calculateSubscriptionOverage, getPersonalBillingSummary } from '@/lib/billing/core/billing' @@ -51,7 +51,7 @@ describe('getPersonalBillingSummary', () => { vi.clearAllMocks() mockEnsureUserStatsExists.mockResolvedValue(undefined) mockResolveBillingInterval.mockReturnValue('year') - mockComputeDailyRefreshConsumed.mockResolvedValue(1) + mockComputeWeeklyRefreshConsumed.mockResolvedValue(1) mockGetBillingPeriodUsageCostWithSourceSubset.mockResolvedValue({ total: 4, subset: 1 }) mockGetHighestPriorityPersonalSubscription.mockResolvedValue({ id: 'personal-sub', @@ -110,7 +110,7 @@ describe('getPersonalBillingSummary', () => { lastPeriodCost: 6, lastPeriodCopilotCost: 2, }) - expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith( + expect(mockComputeWeeklyRefreshConsumed).toHaveBeenCalledWith( expect.objectContaining({ periodEnd: new Date('2026-08-01T00:00:00.000Z'), billingEntity: { type: 'user', id: 'viewer-a' }, @@ -123,7 +123,7 @@ describe('getPersonalBillingSummary', () => { describe('calculateSubscriptionOverage', () => { beforeEach(() => { vi.clearAllMocks() - mockComputeDailyRefreshConsumed.mockResolvedValue(0) + mockComputeWeeklyRefreshConsumed.mockResolvedValue(0) }) it('bills the pooled org ledger with entity-scoped refresh — no roster read', async () => { @@ -149,11 +149,11 @@ describe('calculateSubscriptionOverage', () => { ) // Refresh is scoped by the same entity stamps as the ledger sum — no // actor list, so departed members' rows participate identically. - expect(mockComputeDailyRefreshConsumed).toHaveBeenCalledWith({ + expect(mockComputeWeeklyRefreshConsumed).toHaveBeenCalledWith({ billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2026-07-01T00:00:00.000Z'), periodEnd: new Date('2026-08-01T00:00:00.000Z'), - planDollars: 40, + weeklyRefreshDollars: 10, seats: 2, }) expect(overage).toBe(80) diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index ff0308e593c..4ef6c3b258f 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -12,8 +12,14 @@ import { getBillingPeriodUsageCost, getBillingPeriodUsageCostWithSourceSubset, } from '@/lib/billing/core/usage-log' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isPaid, isPro, isTeam } from '@/lib/billing/plan-helpers' +import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh' +import { + getPlanWeeklyRefreshDollars, + isEnterprise, + isPaid, + isPro, + isTeam, +} from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getFreeTierLimit, @@ -111,7 +117,7 @@ export async function isSubscriptionOrgScoped(sub: { referenceId: string }): Pro /** * Compute an org's overage amount from an already-fetched pooled ledger sum. - * Internally performs one daily-refresh DB read to subtract refresh credits; + * Internally performs one weekly-refresh DB read to subtract refresh credits; * callers pass the org-attributed ledger usage for the period (threshold * billing passes the current period; cycle close passes the closed period). * All callers route through this to keep the overage math in one place. @@ -126,29 +132,29 @@ export async function computeOrgOverageAmount(params: { }): Promise<{ effectiveUsage: number baseSubscriptionAmount: number - dailyRefreshDeduction: number + weeklyRefreshDeduction: number totalOverage: number }> { const totalUsage = params.pooledLedgerUsage - let dailyRefreshDeduction = 0 - const planDollars = getPlanTierDollars(params.plan) - if (planDollars > 0 && params.periodStart) { - dailyRefreshDeduction = await computeDailyRefreshConsumed({ + let weeklyRefreshDeduction = 0 + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(params.plan) + if (weeklyRefreshDollars > 0 && params.periodStart) { + weeklyRefreshDeduction = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'organization', id: params.organizationId }, periodStart: params.periodStart, periodEnd: params.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, seats: params.seats || 1, }) } - const effectiveUsage = Math.max(0, totalUsage - dailyRefreshDeduction) + const effectiveUsage = Math.max(0, totalUsage - weeklyRefreshDeduction) const { basePrice } = getPlanPricing(params.plan ?? '') const baseSubscriptionAmount = (params.seats || 1) * basePrice const totalOverage = Math.max(0, effectiveUsage - baseSubscriptionAmount) - return { effectiveUsage, baseSubscriptionAmount, dailyRefreshDeduction, totalOverage } + return { effectiveUsage, baseSubscriptionAmount, weeklyRefreshDeduction, totalOverage } } /** @@ -219,15 +225,15 @@ export async function calculateSubscriptionOverage(sub: { ) : 0 - let dailyRefreshDeduction = 0 + let weeklyRefreshDeduction = 0 if (isPro(sub.plan)) { - const planDollars = getPlanTierDollars(sub.plan) - if (planDollars > 0 && sub.periodStart) { - dailyRefreshDeduction = await computeDailyRefreshConsumed({ + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(sub.plan) + if (weeklyRefreshDollars > 0 && sub.periodStart) { + weeklyRefreshDeduction = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: sub.referenceId }, periodStart: sub.periodStart, periodEnd: sub.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, }) } } @@ -235,14 +241,14 @@ export async function calculateSubscriptionOverage(sub: { const { basePrice } = getPlanPricing(sub.plan || 'free') totalOverageDecimal = Decimal.max( 0, - toDecimal(ledgerUsage).minus(toDecimal(dailyRefreshDeduction)).minus(basePrice) + toDecimal(ledgerUsage).minus(toDecimal(weeklyRefreshDeduction)).minus(basePrice) ) logger.info('Calculated personal overage', { subscriptionId: sub.id, plan: sub.plan || 'free', ledgerUsage, - dailyRefreshDeduction, + weeklyRefreshDeduction, basePrice, totalOverage: toNumber(totalOverageDecimal), }) @@ -303,14 +309,14 @@ export async function getPersonalBillingSummary(userId: string, executor: DbClie hasPaidSubscriptionStatus(personalSubscription.status) && personalSubscription.periodStart ) { - const planDollars = getPlanTierDollars(plan) - if (planDollars > 0) { - refreshDeduction = await computeDailyRefreshConsumed( + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(plan) + if (weeklyRefreshDollars > 0) { + refreshDeduction = await computeWeeklyRefreshConsumed( { billingEntity: { type: 'user', id: userId }, periodStart: personalSubscription.periodStart, periodEnd: personalSubscription.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, }, executor ) diff --git a/apps/sim/lib/billing/core/organization.ts b/apps/sim/lib/billing/core/organization.ts index b3ba3e4e112..d33afcd4753 100644 --- a/apps/sim/lib/billing/core/organization.ts +++ b/apps/sim/lib/billing/core/organization.ts @@ -10,8 +10,8 @@ import { getBillingPeriodUsageCostByUser, type UsageQueryPeriod, } from '@/lib/billing/core/usage-log' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' +import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh' +import { getPlanWeeklyRefreshDollars, isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { getEffectiveSeats, getFreeTierLimit, @@ -272,14 +272,14 @@ export async function getOrganizationBillingData( : 0 if (isPaid(subscription.plan) && subscription.periodStart) { - const planDollars = getPlanTierDollars(subscription.plan) - if (planDollars > 0) { - const refreshConsumed = await computeDailyRefreshConsumed( + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(subscription.plan) + if (weeklyRefreshDollars > 0) { + const refreshConsumed = await computeWeeklyRefreshConsumed( { billingEntity: { type: 'organization', id: subscription.referenceId }, periodStart: subscription.periodStart, periodEnd: subscription.periodEnd ?? null, - planDollars, + weeklyRefreshDollars, seats: subscription.seats || 1, }, executor diff --git a/apps/sim/lib/billing/core/usage.test.ts b/apps/sim/lib/billing/core/usage.test.ts index d0bca9f4c7c..de7c5b5725e 100644 --- a/apps/sim/lib/billing/core/usage.test.ts +++ b/apps/sim/lib/billing/core/usage.test.ts @@ -58,8 +58,8 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingPeriodUsageCost: vi.fn(), })) -vi.mock('@/lib/billing/credits/daily-refresh', () => ({ - computeDailyRefreshConsumed: vi.fn(), +vi.mock('@/lib/billing/credits/weekly-refresh', () => ({ + computeWeeklyRefreshConsumed: vi.fn(), })) const { diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 51e19699c18..f87214666d2 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -23,8 +23,13 @@ import { resolveSubscriptionUsagePeriod, } from '@/lib/billing/core/reporting-period' import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isFree, isPaid } from '@/lib/billing/plan-helpers' +import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh' +import { + getPlanWeeklyRefreshDollars, + isEnterprise, + isFree, + isPaid, +} from '@/lib/billing/plan-helpers' import { canEditUsageLimit, getFreeTierLimit, @@ -260,18 +265,18 @@ export async function getResolvedUserUsageData( const billingPeriodStart = billingPeriod.source === 'default' ? null : billingPeriod.start const billingPeriodEnd = billingPeriod.source === 'default' ? null : billingPeriod.end - let dailyRefreshConsumed = 0 + let weeklyRefreshConsumed = 0 if (subscription && isPaid(subscription.plan) && billingPeriodStart) { - const planDollars = getPlanTierDollars(subscription.plan) - if (planDollars > 0) { - dailyRefreshConsumed = await computeDailyRefreshConsumed( + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(subscription.plan) + if (weeklyRefreshDollars > 0) { + weeklyRefreshConsumed = await computeWeeklyRefreshConsumed( { billingEntity: orgScoped ? { type: 'organization', id: subscription.referenceId } : { type: 'user', id: userId }, periodStart: billingPeriodStart, periodEnd: billingPeriodEnd, - planDollars, + weeklyRefreshDollars, seats: orgScoped ? subscription.seats || 1 : undefined, }, executor @@ -279,7 +284,7 @@ export async function getResolvedUserUsageData( } } - const effectiveUsage = Math.max(0, currentUsage - dailyRefreshConsumed) + const effectiveUsage = Math.max(0, currentUsage - weeklyRefreshConsumed) const percentUsed = limit > 0 ? Math.min((effectiveUsage / limit) * 100, 100) : 0 const isWarning = percentUsed >= 80 const isExceeded = effectiveUsage >= limit @@ -628,7 +633,7 @@ export async function syncUsageLimitsFromSubscription(userId: string): Promise { } }) -vi.mock('@/lib/billing/constants', () => ({ - DAILY_REFRESH_RATE: 0.01, -})) - import { - computeBillingPeriodUsageWithDailyRefresh, - computeDailyRefreshConsumed, -} from '@/lib/billing/credits/daily-refresh' + computeBillingPeriodUsageWithWeeklyRefresh, + computeWeeklyRefreshConsumed, +} from '@/lib/billing/credits/weekly-refresh' /** * Refresh caps windows at `Date.now()`, so the suite pins the clock after @@ -40,7 +36,7 @@ afterAll(() => { vi.useRealTimers() }) -describe('computeBillingPeriodUsageWithDailyRefresh', () => { +describe('computeBillingPeriodUsageWithWeeklyRefresh', () => { const periodStart = new Date('2026-03-01T00:00:00.000Z') const periodEnd = new Date('2026-04-01T00:00:00.000Z') @@ -48,21 +44,21 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { vi.clearAllMocks() }) - it('keeps the exact ledger end bound while computing refresh from daily buckets', async () => { + it('keeps the exact ledger end bound while computing refresh from weekly buckets', async () => { dbChainMockFns.groupBy.mockResolvedValueOnce([ - { ledgerTotal: '12.50', refreshDayTotal: '0.50' }, - { ledgerTotal: '12.50', refreshDayTotal: '0.10' }, + { ledgerTotal: '25.00', refreshWeekTotal: '12.00' }, + { ledgerTotal: '25.00', refreshWeekTotal: '4.00' }, ]) await expect( - computeBillingPeriodUsageWithDailyRefresh({ + computeBillingPeriodUsageWithWeeklyRefresh({ billingEntity: { type: 'organization', id: 'org-1' }, billingPeriod: { start: periodStart, end: periodEnd }, refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, - planDollars: 25, + weeklyRefreshDollars: 10, }) - ).resolves.toEqual({ ledgerUsage: 12.5, refreshConsumed: 0.35 }) + ).resolves.toEqual({ ledgerUsage: 25, refreshConsumed: 14 }) expect(drizzleOrmMock.eq).toHaveBeenCalledWith( schemaMock.usageLog.billingPeriodStart, @@ -75,10 +71,10 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { const reportingStart = new Date('2026-01-01T00:00:00.000Z') const reportingEnd = new Date('2027-01-01T00:00:00.000Z') dbChainMockFns.groupBy.mockResolvedValueOnce([ - { ledgerTotal: '20.00', refreshDayTotal: '0.20' }, + { ledgerTotal: '20.00', refreshWeekTotal: '0.20' }, ]) - await computeBillingPeriodUsageWithDailyRefresh({ + await computeBillingPeriodUsageWithWeeklyRefresh({ billingEntity: { type: 'user', id: 'user-1' }, billingPeriod: { start: reportingStart, @@ -87,7 +83,7 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { }, refreshPeriodStart: periodStart, refreshPeriodEnd: periodEnd, - planDollars: 25, + weeklyRefreshDollars: 10, }) expect(drizzleOrmMock.gte).toHaveBeenCalledWith(schemaMock.usageLog.createdAt, reportingStart) @@ -103,40 +99,40 @@ describe('computeBillingPeriodUsageWithDailyRefresh', () => { }) }) -describe('computeDailyRefreshConsumed', () => { +describe('computeWeeklyRefreshConsumed', () => { beforeEach(() => { vi.clearAllMocks() }) - it('returns 0 when planDollars is 0', async () => { - const result = await computeDailyRefreshConsumed({ + it('returns 0 when weeklyRefreshDollars is 0', async () => { + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), - planDollars: 0, + weeklyRefreshDollars: 0, }) expect(result).toBe(0) expect(dbChainMockFns.groupBy).not.toHaveBeenCalled() }) it('returns 0 when periodEnd is before periodStart', async () => { - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-10'), periodEnd: new Date('2026-03-01'), - planDollars: 25, + weeklyRefreshDollars: 10, }) expect(result).toBe(0) }) it('scopes rows by the entity and period stamps, never an actor list', async () => { - dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '0.10' }]) + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 0, weekTotal: '0.10' }]) const periodStart = new Date('2026-03-01') - await computeDailyRefreshConsumed({ + await computeWeeklyRefreshConsumed({ billingEntity: { type: 'organization', id: 'org-1' }, periodStart, - periodEnd: new Date('2026-03-02'), - planDollars: 25, + periodEnd: new Date('2026-03-08'), + weeklyRefreshDollars: 10, }) expect(drizzleOrmMock.eq).toHaveBeenCalledWith( @@ -154,19 +150,20 @@ describe('computeDailyRefreshConsumed', () => { it('keeps straggler rows stamped to the period but written after its end', async () => { // A run that started before the rollover inserts rows stamped with the // elapsed period after it ended; the stamp-based close bills them, so the - // deduction must include them too (clamped into the final day bucket). - dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 30, dayTotal: '0.30' }]) + // deduction must include them too (clamped into the final week bucket — + // index 4 for a 31-day period). + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 4, weekTotal: '12.00' }]) const periodStart = new Date('2026-03-01') const periodEnd = new Date('2026-04-01') - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart, periodEnd, - planDollars: 25, + weeklyRefreshDollars: 10, }) - expect(result).toBe(0.25) + expect(result).toBe(10) // Membership is stamp-only: no created-at bound may exclude a row the // stamped ledger total includes. expect(drizzleOrmMock.lt).not.toHaveBeenCalledWith(schemaMock.usageLog.createdAt, periodEnd) @@ -175,91 +172,122 @@ describe('computeDailyRefreshConsumed', () => { it('rejects windows beyond the supported annual bound', async () => { await expect( - computeDailyRefreshConsumed({ + computeWeeklyRefreshConsumed({ billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2024-01-01'), periodEnd: new Date('2026-03-01'), - planDollars: 25, + weeklyRefreshDollars: 10, }) ).rejects.toThrow('annual bound') expect(dbChainMockFns.groupBy).not.toHaveBeenCalled() }) - it('caps each day at the daily refresh allowance', async () => { + it('caps each week at the weekly refresh allowance', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([ + { weekIndex: 0, weekTotal: '15.00' }, + { weekIndex: 1, weekTotal: '2.00' }, + { weekIndex: 2, weekTotal: '50.00' }, + ]) + + const result = await computeWeeklyRefreshConsumed({ + billingEntity: { type: 'user', id: 'user-1' }, + periodStart: new Date('2026-03-01'), + periodEnd: new Date('2026-03-22'), + weeklyRefreshDollars: 10, + }) + + // All usage inside a 7-day window shares one $10 allowance: + // Week 0: MIN(15.00, 10) = 10 + // Week 1: MIN(2.00, 10) = 2 + // Week 2: MIN(50.00, 10) = 10 + // Total = 22 + expect(result).toBe(22) + }) + + it('grants the full allowance to a partial final week', async () => { + // 9-day period = one full week + a 2-day partial week. The MIN cap never + // prorates: the partial window still carries the full $10 allowance. dbChainMockFns.groupBy.mockResolvedValueOnce([ - { dayIndex: 0, dayTotal: '0.50' }, - { dayIndex: 1, dayTotal: '0.10' }, - { dayIndex: 2, dayTotal: '1.00' }, + { weekIndex: 0, weekTotal: '3.00' }, + { weekIndex: 1, weekTotal: '50.00' }, ]) - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), - periodEnd: new Date('2026-03-04'), - planDollars: 25, + periodEnd: new Date('2026-03-10'), + weeklyRefreshDollars: 10, + }) + + expect(result).toBe(13) + }) + + it('caps an open-ended period at now', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 1, weekTotal: '12.00' }]) + + const result = await computeWeeklyRefreshConsumed({ + billingEntity: { type: 'user', id: 'user-1' }, + periodStart: new Date('2026-08-01'), + periodEnd: null, + weeklyRefreshDollars: 10, }) - // Daily refresh = $25 * 0.01 = $0.25/day - // Day 0: MIN(0.50, 0.25) = 0.25 - // Day 1: MIN(0.10, 0.25) = 0.10 - // Day 2: MIN(1.00, 0.25) = 0.25 - // Total = 0.60 - expect(result).toBe(0.6) + expect(result).toBe(10) }) it('returns 0 when no usage rows exist', async () => { dbChainMockFns.groupBy.mockResolvedValueOnce([]) - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), - periodEnd: new Date('2026-03-04'), - planDollars: 25, + periodEnd: new Date('2026-03-22'), + weeklyRefreshDollars: 10, }) expect(result).toBe(0) }) - it('multiplies daily refresh by seats', async () => { - dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '2.00' }]) + it('multiplies the weekly allowance by seats', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 0, weekTotal: '40.00' }]) - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2026-03-01'), - periodEnd: new Date('2026-03-02'), - planDollars: 100, + periodEnd: new Date('2026-03-08'), + weeklyRefreshDollars: 20, seats: 3, }) - // Daily refresh = $100 * 0.01 * 3 seats = $3.00/day - // Day 0: MIN(2.00, 3.00) = 2.00 - expect(result).toBe(2.0) + // Weekly allowance = $20 * 3 seats = $60/week + // Week 0: MIN(40.00, 60.00) = 40.00 + expect(result).toBe(40) }) - it('caps at refresh even with high usage and multiple seats', async () => { - dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: '50.00' }]) + it('caps at the allowance even with high usage and multiple seats', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 0, weekTotal: '500.00' }]) - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'organization', id: 'org-1' }, periodStart: new Date('2026-03-01'), - periodEnd: new Date('2026-03-02'), - planDollars: 100, + periodEnd: new Date('2026-03-08'), + weeklyRefreshDollars: 20, seats: 2, }) - // Daily refresh = $100 * 0.01 * 2 seats = $2.00/day - // Day 0: MIN(50.00, 2.00) = 2.00 - expect(result).toBe(2.0) + // Weekly allowance = $20 * 2 seats = $40/week + // Week 0: MIN(500.00, 40.00) = 40.00 + expect(result).toBe(40) }) - it('handles null dayTotal gracefully', async () => { - dbChainMockFns.groupBy.mockResolvedValueOnce([{ dayIndex: 0, dayTotal: null }]) + it('handles null weekTotal gracefully', async () => { + dbChainMockFns.groupBy.mockResolvedValueOnce([{ weekIndex: 0, weekTotal: null }]) - const result = await computeDailyRefreshConsumed({ + const result = await computeWeeklyRefreshConsumed({ billingEntity: { type: 'user', id: 'user-1' }, periodStart: new Date('2026-03-01'), - periodEnd: new Date('2026-03-02'), - planDollars: 25, + periodEnd: new Date('2026-03-08'), + weeklyRefreshDollars: 10, }) expect(result).toBe(0) diff --git a/apps/sim/lib/billing/credits/daily-refresh.ts b/apps/sim/lib/billing/credits/weekly-refresh.ts similarity index 66% rename from apps/sim/lib/billing/credits/daily-refresh.ts rename to apps/sim/lib/billing/credits/weekly-refresh.ts index de16c15f63a..8779fc7389f 100644 --- a/apps/sim/lib/billing/credits/daily-refresh.ts +++ b/apps/sim/lib/billing/credits/weekly-refresh.ts @@ -1,12 +1,14 @@ /** - * Daily Refresh Credits + * Weekly 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). + * Each billing period is divided into 7-day windows starting from `periodStart`. + * Paid plans receive a fixed `weeklyRefreshDollars * seats` allowance of + * "included" usage per window. Usage within that allowance does not count toward + * the plan limit (use-it-or-lose-it). A partial final window receives the full + * allowance — the MIN cap never prorates. * * The total refresh consumed in a period is: - * SUM( MIN(day_usage, daily_refresh_amount) ) for each day + * SUM( MIN(week_usage, weekly_allowance) ) for each week * * This is subtracted from ledger period usage to derive "effective billable usage". * @@ -22,35 +24,34 @@ import { db } from '@sim/db' import { usageLog } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, gte, lt, or, sql, sum } from 'drizzle-orm' -import { DAILY_REFRESH_RATE } from '@/lib/billing/constants' import type { BillingEntity, UsageQueryPeriod } from '@/lib/billing/core/usage-log' import type { DbClient } from '@/lib/db/types' -const logger = createLogger('DailyRefresh') +const logger = createLogger('WeeklyRefresh') const MS_PER_DAY = 86_400_000 const MAX_BILLING_PERIOD_DAYS = 370 -interface BillingPeriodUsageWithDailyRefreshParams { +interface BillingPeriodUsageWithWeeklyRefreshParams { billingEntity: BillingEntity billingPeriod: UsageQueryPeriod refreshPeriodStart: Date refreshPeriodEnd?: Date | null - planDollars: number + weeklyRefreshDollars: number seats?: number } /** - * Reads the exact ledger total and the daily-refresh buckets from one snapshot. + * Reads the exact ledger total and the weekly-refresh buckets from one snapshot. * * The two aggregates intentionally keep different predicates. Ledger totals * use both captured period bounds (or a reporting-time window), while refresh * membership is the captured period-start stamp alone — created-at only - * buckets rows into days, clamped into the period (see - * `computeDailyRefreshConsumed` for why). + * buckets rows into weeks, clamped into the period (see + * `computeWeeklyRefreshConsumed` for why). */ -export async function computeBillingPeriodUsageWithDailyRefresh( - params: BillingPeriodUsageWithDailyRefreshParams, +export async function computeBillingPeriodUsageWithWeeklyRefresh( + params: BillingPeriodUsageWithWeeklyRefreshParams, executor: DbClient = db ): Promise<{ ledgerUsage: number; refreshConsumed: number }> { const { @@ -58,12 +59,12 @@ export async function computeBillingPeriodUsageWithDailyRefresh( billingPeriod, refreshPeriodStart, refreshPeriodEnd, - planDollars, + weeklyRefreshDollars, seats = 1, } = params const now = new Date() const cap = refreshPeriodEnd && refreshPeriodEnd < now ? refreshPeriodEnd : now - const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats + const weeklyAllowanceDollars = weeklyRefreshDollars * seats const refreshWindowActive = cap > refreshPeriodStart const refreshFilter = refreshWindowActive ? eq(usageLog.billingPeriodStart, refreshPeriodStart) @@ -89,16 +90,16 @@ export async function computeBillingPeriodUsageWithDailyRefresh( const capEpoch = Math.floor(cap.getTime() / 1000) const rows = await executor .select({ - dayIndex: - sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as( - 'day_index' + weekIndex: + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( + 'week_index' ), ledgerTotal: sql`SUM(SUM(${usageLog.cost}) FILTER (WHERE ${ledgerPeriodFilter})) OVER ()`.as( 'ledger_total' ), - refreshDayTotal: sql`SUM(${usageLog.cost}) FILTER (WHERE ${refreshFilter})`.as( - 'refresh_day_total' + refreshWeekTotal: sql`SUM(${usageLog.cost}) FILTER (WHERE ${refreshFilter})`.as( + 'refresh_week_total' ), }) .from(usageLog) @@ -109,12 +110,12 @@ export async function computeBillingPeriodUsageWithDailyRefresh( scanFilter ) ) - .groupBy(sql`day_index`) + .groupBy(sql`week_index`) let refreshConsumed = 0 for (const row of rows) { - const dayUsage = Number.parseFloat(row.refreshDayTotal ?? '0') - refreshConsumed += Math.min(dayUsage, dailyRefreshDollars) + const weekUsage = Number.parseFloat(row.refreshWeekTotal ?? '0') + refreshConsumed += Math.min(weekUsage, weeklyAllowanceDollars) } return { @@ -124,32 +125,32 @@ export async function computeBillingPeriodUsageWithDailyRefresh( } /** - * Compute the total daily refresh credits a billing entity consumed in a - * period, using a single aggregating SQL query grouped by day offset. + * Compute the total weekly refresh credits a billing entity consumed in a + * period, using a single aggregating SQL query grouped by week offset. * - * For each day from `periodStart`: - * consumed_today = MIN(actual_usage_today, daily_refresh_dollars) + * For each 7-day window from `periodStart`: + * consumed_this_week = MIN(actual_usage_this_week, weekly_allowance_dollars) * * Rows are scoped purely by the entity and period stamps — see the module * header for why no actor list participates. * - * @returns Total dollars of refresh consumed across all days (to subtract from usage) + * @returns Total dollars of refresh consumed across all weeks (to subtract from usage) */ -export async function computeDailyRefreshConsumed( +export async function computeWeeklyRefreshConsumed( params: { billingEntity: BillingEntity periodStart: Date periodEnd?: Date | null - planDollars: number + weeklyRefreshDollars: number seats?: number }, executor: DbClient = db ): Promise { - const { billingEntity, periodStart, periodEnd, planDollars, seats = 1 } = params + const { billingEntity, periodStart, periodEnd, weeklyRefreshDollars, seats = 1 } = params - if (planDollars <= 0) return 0 + if (weeklyRefreshDollars <= 0) return 0 - const dailyRefreshDollars = planDollars * DAILY_REFRESH_RATE * seats + const weeklyAllowanceDollars = weeklyRefreshDollars * seats const now = new Date() const cap = periodEnd && periodEnd < now ? periodEnd : now @@ -162,19 +163,19 @@ export async function computeDailyRefreshConsumed( } // Membership mirrors the ledger sums exactly: the entity and period stamps - // alone. Created-at only assigns the day bucket, clamped into the period — + // alone. Created-at only assigns the week bucket, clamped into the period — // a straggler row written after the rollover (billing attribution is frozen // at run start) is billed by the stamp-based close, so it must consume - // refresh on the period's final day rather than fall out of the deduction. + // refresh in the period's final week rather than fall out of the deduction. const startEpoch = Math.floor(periodStart.getTime() / 1000) const capEpoch = Math.floor(cap.getTime() / 1000) const rows = await executor .select({ - dayIndex: - sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 86400)`.as( - 'day_index' + weekIndex: + sql`FLOOR((LEAST(GREATEST(EXTRACT(EPOCH FROM ${usageLog.createdAt}), ${startEpoch}), ${capEpoch - 1}) - ${startEpoch}) / 604800)`.as( + 'week_index' ), - dayTotal: sum(usageLog.cost).as('day_total'), + weekTotal: sum(usageLog.cost).as('week_total'), }) .from(usageLog) .where( @@ -184,19 +185,19 @@ export async function computeDailyRefreshConsumed( eq(usageLog.billingPeriodStart, periodStart) ) ) - .groupBy(sql`day_index`) + .groupBy(sql`week_index`) let totalConsumed = 0 for (const row of rows) { - const dayUsage = Number.parseFloat(row.dayTotal ?? '0') - totalConsumed += Math.min(dayUsage, dailyRefreshDollars) + const weekUsage = Number.parseFloat(row.weekTotal ?? '0') + totalConsumed += Math.min(weekUsage, weeklyAllowanceDollars) } - logger.debug('Daily refresh computed', { + logger.debug('Weekly refresh computed', { billingEntityType: billingEntity.type, periodStart: periodStart.toISOString(), - days: dayCount, - dailyRefreshDollars, + weeks: Math.ceil(dayCount / 7), + weeklyAllowanceDollars, totalConsumed, }) diff --git a/apps/sim/lib/billing/cycle-close.test.ts b/apps/sim/lib/billing/cycle-close.test.ts index 7e90b861e21..b9f3237444f 100644 --- a/apps/sim/lib/billing/cycle-close.test.ts +++ b/apps/sim/lib/billing/cycle-close.test.ts @@ -14,10 +14,10 @@ const { mockComputeOrgOverageAmount, mockIsSubscriptionOrgScoped, mockGetStampedPeriodRangeUsageCostByUser, - mockComputeDailyRefreshConsumed, + mockComputeWeeklyRefreshConsumed, mockEnqueueOutboxEvent, mockGetPlanPricing, - mockGetPlanTierDollars, + mockGetPlanWeeklyRefreshDollars, mockResolveSubscriptionUsagePeriod, mockIsEnterprise, mockIsFree, @@ -27,10 +27,10 @@ const { mockComputeOrgOverageAmount: vi.fn(), mockIsSubscriptionOrgScoped: vi.fn(), mockGetStampedPeriodRangeUsageCostByUser: vi.fn(), - mockComputeDailyRefreshConsumed: vi.fn(), + mockComputeWeeklyRefreshConsumed: vi.fn(), mockEnqueueOutboxEvent: vi.fn(), mockGetPlanPricing: vi.fn(), - mockGetPlanTierDollars: vi.fn(), + mockGetPlanWeeklyRefreshDollars: vi.fn(), mockResolveSubscriptionUsagePeriod: vi.fn(), mockIsEnterprise: vi.fn(), mockIsFree: vi.fn(), @@ -58,12 +58,12 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ getStampedPeriodRangeUsageCostByUser: mockGetStampedPeriodRangeUsageCostByUser, })) -vi.mock('@/lib/billing/credits/daily-refresh', () => ({ - computeDailyRefreshConsumed: mockComputeDailyRefreshConsumed, +vi.mock('@/lib/billing/credits/weekly-refresh', () => ({ + computeWeeklyRefreshConsumed: mockComputeWeeklyRefreshConsumed, })) vi.mock('@/lib/billing/plan-helpers', () => ({ - getPlanTierDollars: mockGetPlanTierDollars, + getPlanWeeklyRefreshDollars: mockGetPlanWeeklyRefreshDollars, isEnterprise: mockIsEnterprise, isFree: mockIsFree, })) @@ -167,14 +167,14 @@ describe('closeElapsedBillingPeriod', () => { mockIsEnterprise.mockReturnValue(false) mockIsFree.mockReturnValue(false) mockResolveSubscriptionUsagePeriod.mockReturnValue(null) - mockGetPlanTierDollars.mockReturnValue(40) + mockGetPlanWeeklyRefreshDollars.mockReturnValue(10) mockGetPlanPricing.mockReturnValue({ basePrice: 40 }) - mockComputeDailyRefreshConsumed.mockResolvedValue(0) + mockComputeWeeklyRefreshConsumed.mockResolvedValue(0) mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]])) mockComputeOrgOverageAmount.mockResolvedValue({ effectiveUsage: 150, baseSubscriptionAmount: 80, - dailyRefreshDeduction: 0, + weeklyRefreshDeduction: 0, totalOverage: 70, }) dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) @@ -513,14 +513,14 @@ describe('closeElapsedPeriodBeforeDeletion', () => { mockIsEnterprise.mockReturnValue(false) mockIsFree.mockReturnValue(false) mockResolveSubscriptionUsagePeriod.mockReturnValue(null) - mockGetPlanTierDollars.mockReturnValue(40) + mockGetPlanWeeklyRefreshDollars.mockReturnValue(10) mockGetPlanPricing.mockReturnValue({ basePrice: 40 }) - mockComputeDailyRefreshConsumed.mockResolvedValue(0) + mockComputeWeeklyRefreshConsumed.mockResolvedValue(0) mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['owner-1', 150]])) mockComputeOrgOverageAmount.mockResolvedValue({ effectiveUsage: 150, baseSubscriptionAmount: 80, - dailyRefreshDeduction: 0, + weeklyRefreshDeduction: 0, totalOverage: 70, }) dbChainMockFns.returning.mockResolvedValue([{ id: 'sub-1' }]) diff --git a/apps/sim/lib/billing/cycle-close.ts b/apps/sim/lib/billing/cycle-close.ts index 725c379cf29..44a7bf6aba9 100644 --- a/apps/sim/lib/billing/cycle-close.ts +++ b/apps/sim/lib/billing/cycle-close.ts @@ -17,8 +17,8 @@ import { COPILOT_USAGE_SOURCES, getStampedPeriodRangeUsageCostByUser, } from '@/lib/billing/core/usage-log' -import { computeDailyRefreshConsumed } from '@/lib/billing/credits/daily-refresh' -import { getPlanTierDollars, isEnterprise, isFree } from '@/lib/billing/plan-helpers' +import { computeWeeklyRefreshConsumed } from '@/lib/billing/credits/weekly-refresh' +import { getPlanWeeklyRefreshDollars, isEnterprise, isFree } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES, getPlanPricing } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers' @@ -435,14 +435,14 @@ export async function closeElapsedBillingPeriod( }) totalOverage = computed } else { - const planDollars = getPlanTierDollars(sub.plan) + const weeklyRefreshDollars = getPlanWeeklyRefreshDollars(sub.plan) let refreshConsumed = 0 - if (planDollars > 0) { - refreshConsumed = await computeDailyRefreshConsumed({ + if (weeklyRefreshDollars > 0) { + refreshConsumed = await computeWeeklyRefreshConsumed({ billingEntity, periodStart: closeFrom, periodEnd: periodStart, - planDollars, + weeklyRefreshDollars, }) } const { basePrice } = getPlanPricing(sub.plan) diff --git a/apps/sim/lib/billing/plan-helpers.ts b/apps/sim/lib/billing/plan-helpers.ts index 120924b3561..9d702919110 100644 --- a/apps/sim/lib/billing/plan-helpers.ts +++ b/apps/sim/lib/billing/plan-helpers.ts @@ -14,9 +14,12 @@ import type { AnyColumn } from 'drizzle-orm' import { eq, like, or, type SQL } from 'drizzle-orm' import { CREDIT_TIERS, + CREDITS_PER_DOLLAR, DEFAULT_PRO_TIER_COST_LIMIT, DEFAULT_TEAM_TIER_COST_LIMIT, + MAX_CREDIT_TIER, MAX_TIER_CREDITS, + PRO_CREDIT_TIER, } from '@/lib/billing/constants' export type PlanCategory = 'free' | 'pro' | 'team' | 'enterprise' @@ -99,6 +102,24 @@ export function getPlanTierDollars(plan: string | null | undefined): number { return 0 } +/** + * Weekly refresh allowance for a plan, in dollars per seat per week. + * + * Fixed per tier, not a rate: sub-Max paid plans — pro_6000, team_6000, and + * legacy 'pro'/'team' — take the Pro allowance ($10/week = 2,000 credits); + * Max-allocation plans (pro_25000, team_25000) take the Max allowance + * ($20/week = 4,000 credits). Free and enterprise get 0. + * + * Deliberately NOT `isMaxTier` — that predicate includes enterprise, which + * must resolve to 0 here (enterprise has no refresh, matching its + * `getPlanTierDollars('enterprise') === 0` behavior under the old rate). + */ +export function getPlanWeeklyRefreshDollars(plan: string | null | undefined): number { + if (!isPaid(plan) || isEnterprise(plan)) return 0 + const tier = getPlanTierCredits(plan) >= MAX_TIER_CREDITS ? MAX_CREDIT_TIER : PRO_CREDIT_TIER + return tier.weeklyRefreshCredits / CREDITS_PER_DOLLAR +} + /** * Return the broad plan category regardless of tier suffix. */ diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index ca695e27bbb..4f680b962de 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -792,19 +792,19 @@ export const simProfile: CompetitorProfile = { }, freeTier: { value: - 'Yes: Free plan with 1,000 monthly credits (worth $5, env-configurable), granted monthly with no daily refresh (daily refresh is a paid-plan feature)', + 'Yes: Free plan with 1,000 monthly credits (worth $5, env-configurable), granted monthly with no weekly refresh (weekly refresh is a paid-plan feature)', shortValue: 'Free plan, 1,000 credits/month', confidence: 'verified', sources: [ { url: 'https://www.sim.ai/pricing', label: 'Sim Pricing', - asOf: '2026-07-08', + asOf: '2026-08-26', }, { url: 'https://github.com/simstudioai/sim/blob/main/apps/sim/lib/billing/constants.ts', label: 'Sim codebase: DEFAULT_FREE_CREDITS', - asOf: '2026-07-08', + asOf: '2026-08-26', }, ], }, diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 770f32167de..8f7312ccc6b 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -3872,7 +3872,7 @@ export const usageLog = pgTable( * a heap fetch per matched row. * * `userId`/`createdAt` sit immediately after the shared equality prefix because - * the daily-refresh rollup filters on them and NOT on `billingPeriodEnd`; + * the weekly-refresh rollup filters on them and NOT on `billingPeriodEnd`; * putting `billingPeriodEnd` in that slot would end the usable prefix at * `billingPeriodStart` and leave that query scanning the whole period. * `billingPeriodEnd` is functionally determined by `billingPeriodStart`, so it From a272839244342577b2e68ff0513ef58886eeb8ea Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 26 Aug 2026 12:32:10 -0700 Subject: [PATCH 2/2] fix(billing): scope weekly refresh row seat scaling to organization billing --- .../[workspaceId]/settings/components/billing/billing.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index eb01f3d43cb..b0fc26e75d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -440,7 +440,8 @@ export function Billing({ scope, organizationId, creditUsageHref }: BillingProps : subscriptionData?.data?.cancelAtPeriodEnd === true const weeklyRefreshDollars = - getPlanWeeklyRefreshDollars(subscription.plan) * (subscription.seats || 1) + getPlanWeeklyRefreshDollars(subscription.plan) * + (isOrganizationScope ? subscription.seats || 1 : 1) const invoices = (invoicesData?.invoices ?? []).map((invoice) => ({ id: invoice.id,