Skip to content

Commit 37a2eea

Browse files
committed
test(billing): cover a terminal claim overlapping an in-flight charge
Both lock orders against real PostgreSQL: a claim waits for an in-flight charge so the final sum includes it, and a charge that waited on an in-flight claim is refused.
1 parent 577323f commit 37a2eea

1 file changed

Lines changed: 70 additions & 1 deletion

File tree

‎apps/sim/lib/billing/core/usage-log.integration.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { db } from '@sim/db'
99
import * as schema from '@sim/db/schema'
1010
import { readTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
1111
import { getPostgresErrorCode } from '@sim/utils/errors'
12+
import { sleep } from '@sim/utils/helpers'
1213
import { generateId } from '@sim/utils/id'
1314
import { eq, sql } from 'drizzle-orm'
1415
import { drizzle } from 'drizzle-orm/postgres-js'
@@ -20,7 +21,10 @@ const databaseUrl = readTestDatabaseUrl()
2021

2122
vi.mock('@sim/db', () => ({ db: { transaction }, dbReplica: {} }))
2223
vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: vi.fn() }))
23-
vi.mock('@/lib/billing/subscriptions/utils', () => ({ isOrgScopedSubscription: vi.fn() }))
24+
vi.mock('@/lib/billing/subscriptions/utils', async (importOriginal) => ({
25+
...(await importOriginal<typeof import('@/lib/billing/subscriptions/utils')>()),
26+
isOrgScopedSubscription: vi.fn(),
27+
}))
2428

2529
import {
2630
CumulativeUsageContextMismatchError,
@@ -31,6 +35,7 @@ import {
3135
type RecordCumulativeUsageParams,
3236
recordCumulativeUsage,
3337
} from '@/lib/billing/core/usage-log'
38+
import { claimTerminalPeriod } from '@/lib/billing/cycle-close'
3439

3540
const require = createRequire(import.meta.url)
3641
const commonJsPostgres = require('postgres') as typeof postgres
@@ -549,6 +554,70 @@ describe('Cumulative billing with PostgreSQL', () => {
549554
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
550555
})
551556

557+
/**
558+
* Resolves true once a session waits on a row lock of the subscription table, or false once
559+
* `work` settles without anyone waiting, so a missing lock fails instead of hanging.
560+
*/
561+
async function waitsOnSubscriptionRow(work: Promise<unknown>) {
562+
let settled = false
563+
work.then(
564+
() => {
565+
settled = true
566+
},
567+
() => {
568+
settled = true
569+
}
570+
)
571+
while (!settled) {
572+
const [row] = await connection<{ waiting: boolean }[]>`
573+
select exists (
574+
select 1 from pg_locks
575+
where locktype = 'tuple' and relation = 'subscription'::regclass
576+
) as waiting
577+
`
578+
if (row.waiting) return true
579+
await sleep(10)
580+
}
581+
return false
582+
}
583+
584+
it('makes the terminal claim wait for an in-flight charge, so the final sum includes it', async () => {
585+
await setSubscriptionPeriod(0)
586+
await charge(0.4)
587+
const pause = pauseNextTransaction()
588+
const inFlight = charge(0.6)
589+
let claim: Promise<unknown> = Promise.resolve()
590+
try {
591+
await pause.reached.promise
592+
claim = claimTerminalPeriod('sub-1')
593+
expect(await waitsOnSubscriptionRow(claim)).toBe(true)
594+
} finally {
595+
pause.release.resolve()
596+
await inFlight
597+
await claim
598+
}
599+
expect(await stampedTotal(0)).toBeCloseTo(0.6, 9)
600+
await expect(charge(0.8)).rejects.toBeInstanceOf(CumulativeUsagePeriodClosedError)
601+
})
602+
603+
it('refuses a charge that waited on an in-flight terminal claim', async () => {
604+
await setSubscriptionPeriod(0)
605+
await charge(0.4)
606+
const pause = pauseNextTransaction()
607+
const claim = claimTerminalPeriod('sub-1')
608+
let late: Promise<unknown> = Promise.resolve()
609+
try {
610+
await pause.reached.promise
611+
late = charge(0.6)
612+
expect(await waitsOnSubscriptionRow(late)).toBe(true)
613+
} finally {
614+
pause.release.resolve()
615+
await claim
616+
}
617+
await expect(late).rejects.toBeInstanceOf(CumulativeUsagePeriodClosedError)
618+
expect(await stampedTotal(0)).toBeCloseTo(0.4, 9)
619+
})
620+
552621
it('holds an early period-start move until an in-flight top-up commits', async () => {
553622
const start = new Date(Date.now() - 24 * 60 * 60 * 1000)
554623
const end = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)

0 commit comments

Comments
 (0)