Skip to content

Commit 6016cc4

Browse files
icecrasher321claude
andcommitted
fix(billing): union departed ledger actors in org threshold settlement
Organization threshold billing now reads the period ledger per user and unions the actors holding org-attributed rows with the current roster before computing refresh deductions — the same actor set calculateSubscriptionOverage and the cycle close use — so a departed member's usage cannot be settled without their daily-refresh offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c8cd452 commit 6016cc4

2 files changed

Lines changed: 23 additions & 11 deletions

File tree

apps/sim/lib/billing/threshold-billing.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const {
1010
mockEnqueueOutboxEvent,
1111
mockGetEffectiveBillingStatus,
1212
mockGetHighestPrioritySubscription,
13-
mockGetBillingPeriodUsageCost,
13+
mockGetBillingPeriodUsageCostByUser,
1414
mockGetOrganizationSubscriptionUsable,
1515
mockHasUsableSubscriptionAccess,
1616
mockIsEnterprise,
@@ -26,7 +26,7 @@ const {
2626
mockEnqueueOutboxEvent: vi.fn(),
2727
mockGetEffectiveBillingStatus: vi.fn(),
2828
mockGetHighestPrioritySubscription: vi.fn(),
29-
mockGetBillingPeriodUsageCost: vi.fn(),
29+
mockGetBillingPeriodUsageCostByUser: vi.fn(),
3030
mockGetOrganizationSubscriptionUsable: vi.fn(),
3131
mockHasUsableSubscriptionAccess: vi.fn(),
3232
mockIsEnterprise: vi.fn(),
@@ -60,7 +60,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({
6060
}))
6161

6262
vi.mock('@/lib/billing/core/usage-log', () => ({
63-
getBillingPeriodUsageCost: mockGetBillingPeriodUsageCost,
63+
getBillingPeriodUsageCostByUser: mockGetBillingPeriodUsageCostByUser,
6464
}))
6565

6666
vi.mock('@/lib/billing/cycle-close', () => ({
@@ -184,7 +184,7 @@ describe('checkAndBillOverageThreshold', () => {
184184
mockIsFree.mockReturnValue(false)
185185
mockIsEnterprise.mockReturnValue(false)
186186
mockIsOrgScopedSubscription.mockReturnValue(false)
187-
mockGetBillingPeriodUsageCost.mockResolvedValue(0)
187+
mockGetBillingPeriodUsageCostByUser.mockResolvedValue(new Map())
188188
mockIsSubscriptionCycleCloseCurrent.mockResolvedValue(true)
189189
})
190190

@@ -609,7 +609,12 @@ describe('checkAndBillOverageThreshold', () => {
609609
mockIsOrgScopedSubscription.mockReturnValue(true)
610610
mockIsOrganizationBillingBlocked.mockResolvedValue(false)
611611
mockGetOrganizationSubscriptionUsable.mockResolvedValue(usableOrgSubscription)
612-
mockGetBillingPeriodUsageCost.mockResolvedValue(350)
612+
mockGetBillingPeriodUsageCostByUser.mockResolvedValue(
613+
new Map([
614+
['owner-1', 300],
615+
['departed-1', 50],
616+
])
617+
)
613618
queueOrgReads()
614619
mockComputeOrgOverageAmount.mockResolvedValue({
615620
totalOverage: 250,
@@ -626,7 +631,7 @@ describe('checkAndBillOverageThreshold', () => {
626631
periodEnd: new Date('2026-06-01T00:00:00.000Z'),
627632
organizationId: userSubscription.referenceId,
628633
pooledLedgerUsage: 350,
629-
memberIds: ['owner-1'],
634+
memberIds: ['owner-1', 'departed-1'],
630635
})
631636
expect(dbChainMockFns.transaction).toHaveBeenCalled()
632637
expect(mockComputeOrgOverageAmount.mock.invocationCallOrder[0]).toBeLessThan(

apps/sim/lib/billing/threshold-billing.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
getHighestPrioritySubscription,
1414
getOrganizationSubscriptionUsable,
1515
} from '@/lib/billing/core/subscription'
16-
import { type BillingEntity, getBillingPeriodUsageCost } from '@/lib/billing/core/usage-log'
16+
import { type BillingEntity, getBillingPeriodUsageCostByUser } from '@/lib/billing/core/usage-log'
1717
import { isSubscriptionCycleCloseCurrent } from '@/lib/billing/cycle-close'
1818
import { isEnterprise, isFree } from '@/lib/billing/plan-helpers'
1919
import {
@@ -615,13 +615,20 @@ async function checkAndBillOrganizationOverageThreshold(
615615
ownerId: usageSnapshot.ownerId,
616616
})
617617

618-
const ledgerUsage =
618+
const orgUsageByUser =
619619
orgSubscription.periodStart && orgSubscription.periodEnd
620-
? await getBillingPeriodUsageCost(
620+
? await getBillingPeriodUsageCostByUser(
621621
{ type: 'organization', id: organizationId },
622622
{ start: orgSubscription.periodStart, end: orgSubscription.periodEnd }
623623
)
624-
: 0
624+
: new Map<string, number>()
625+
let ledgerUsage = 0
626+
for (const cost of orgUsageByUser.values()) ledgerUsage += cost
627+
// Union current members with every actor holding org-attributed rows this
628+
// period: a member who departed mid-period still bills here, so their
629+
// daily-refresh consumption must offset the overage too — same actor set
630+
// as `calculateSubscriptionOverage` and the cycle close.
631+
const overageActorIds = [...new Set([...usageSnapshot.memberIds, ...orgUsageByUser.keys()])]
625632

626633
const {
627634
totalOverage: currentOverage,
@@ -634,7 +641,7 @@ async function checkAndBillOrganizationOverageThreshold(
634641
periodEnd: orgSubscription.periodEnd ?? null,
635642
organizationId,
636643
pooledLedgerUsage: ledgerUsage,
637-
memberIds: usageSnapshot.memberIds,
644+
memberIds: overageActorIds,
638645
})
639646

640647
if (currentOverage < threshold) {

0 commit comments

Comments
 (0)