Skip to content

Commit 313afe5

Browse files
icecrasher321claude
andcommitted
fix(billing): defer ownerless org closes and make the drift test load-bearing
A close with overage due but no owner-role member now defers loudly like the missing-Stripe-identifier case instead of claiming the marker and silently forgiving the money. The stamp-drift test pins the marker before the stamped boundary so only the ledger-stamp lookup can produce the asserted window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ad58f83 commit 313afe5

2 files changed

Lines changed: 30 additions & 4 deletions

File tree

apps/sim/lib/billing/cycle-close.test.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,20 +226,33 @@ describe('closeElapsedBillingPeriod', () => {
226226
})
227227

228228
it('derives the closed window from the ledger period stamps when they drift from calendar math', async () => {
229-
// Rows for the elapsed period are stamped starting Jul 3 (anchor drift);
230-
// the stamped boundary — not periodStart minus one interval — must bound
231-
// the refresh window.
229+
// Rows for the elapsed period are stamped starting Jul 3 (anchor drift)
230+
// while the marker sits at Jul 1: only the stamp lookup can produce the
231+
// Jul 3 bound — calendar math (periodStart minus one interval) would keep
232+
// the window at Jul 1.
232233
const stampedPrevStart = new Date('2026-07-03T00:00:00.000Z')
233234
queueTableRows(schemaMock.usageLog, [{ start: stampedPrevStart }])
234235
queueOrgCloseReads()
235236

236-
await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: stampedPrevStart }))
237+
await closeElapsedBillingPeriod(subRow({ lastClosedPeriodStart: PREV_PERIOD_START }))
237238

238239
expect(mockComputeOrgOverageAmount).toHaveBeenCalledWith(
239240
expect.objectContaining({ periodStart: stampedPrevStart, periodEnd: PERIOD_START })
240241
)
241242
})
242243

244+
it('defers the close when overage is due but the organization has no owner', async () => {
245+
mockGetStampedPeriodRangeUsageCostByUser.mockResolvedValue(new Map([['departed-1', 150]]))
246+
// Member roster has no owner-role row.
247+
queueTableRows(schemaMock.member, [{ userId: 'member-1', role: 'member' }])
248+
249+
const result = await closeElapsedBillingPeriod(subRow())
250+
251+
expect(result.status).toBe('skipped')
252+
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
253+
expect(mockEnqueueOutboxEvent).not.toHaveBeenCalled()
254+
})
255+
243256
it('includes departed members with billed ledger usage in the refresh actor set', async () => {
244257
// 'departed-1' has org-attributed rows in the closed period but no member
245258
// row anymore; their refresh consumption must still offset the overage.

apps/sim/lib/billing/cycle-close.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,19 @@ export async function closeElapsedBillingPeriod(sub: SubscriptionRow): Promise<C
314314
})
315315
return base
316316
}
317+
if (collectMoney && orgScoped && !trackerUserId) {
318+
// Same defer as missing Stripe state: without an owner row there is no
319+
// billed-overage tracker or credit target, and claiming the marker would
320+
// silently forgive the overage. The sweep retries once ownership is
321+
// repaired; mirrors threshold billing's missing-owner handling.
322+
logger.error('Deferring cycle close: overage due but organization has no owner', {
323+
subscriptionId: sub.id,
324+
organizationId: sub.referenceId,
325+
plan: sub.plan,
326+
totalOverage,
327+
})
328+
return base
329+
}
317330

318331
const closeResult = await db.transaction(
319332
async (

0 commit comments

Comments
 (0)