Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import { fetchAssignmentPaymentSplits } from '../../services/payments.service'

import BillingAccountLineItemsModal from './BillingAccountLineItemsModal'

jest.mock('./billing-detail-requests', () => ({
loadBillingDetails: (values: string[], lookup: (value: string) => Promise<unknown>) => (
Promise.all(values.map(lookup))
),
}))

jest.mock('../../../config/routes.config', () => ({
rootRoute: '/work',
}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getCopilotMemberPaymentsBudgetInfo,
} from '../../utils/project-billing-account.utils'

import { loadBillingDetails } from './billing-detail-requests'
import styles from './BillingAccountLineItemsModal.module.scss'

type SortField = 'amount' | 'status' | 'date'
Expand Down Expand Up @@ -566,15 +567,15 @@ function getEngagementPaymentAssignmentIds(items: BillingAccountLineItem[]): str
async function fetchChallengeDetailsById(
challengeIds: string[],
): Promise<ChallengeDetailsById> {
const entries = await Promise.all(challengeIds.map(async challengeId => {
const entries = await loadBillingDetails(challengeIds, async challengeId => {
try {
const challenge = await fetchChallenge(challengeId)

return [challengeId, challenge] as const
} catch {
return undefined
}
}))
})

return new Map(
entries.filter((entry): entry is readonly [string, Challenge] => !!entry),
Expand All @@ -586,21 +587,22 @@ async function fetchChallengeDetailsById(
*
* @param assignmentIds Engagement assignment ids referenced by consumed line items.
* @returns Map keyed by assignment id with finance payment rows.
* @remarks Individual assignment failures are ignored so billing-account
* @remarks Requests share a paced queue with challenge hydration.
* Individual assignment failures are ignored so billing-account
* details still render with existing fallback values.
*/
async function fetchAssignmentPaymentsById(
assignmentIds: string[],
): Promise<AssignmentPaymentsById> {
const entries = await Promise.all(assignmentIds.map(async assignmentId => {
const entries = await loadBillingDetails(assignmentIds, async assignmentId => {
try {
const payments = await fetchAssignmentPaymentSplits(assignmentId)

return [assignmentId, payments] as const
} catch {
return [assignmentId, [] as AssignmentPayment[]] as const
}
}))
})

return new Map(entries)
}
Expand Down Expand Up @@ -1014,8 +1016,9 @@ export const BillingAccountLineItemsModal: FC<BillingAccountLineItemsModalProps>
: undefined,
() => fetchChallengeDetailsById(challengeLineItemIds),
{
errorRetryCount: 2,
shouldRetryOnError: true,
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
},
)
const challengeDetailsById = challengeLineItemIds.length > 0
Expand All @@ -1031,8 +1034,9 @@ export const BillingAccountLineItemsModal: FC<BillingAccountLineItemsModalProps>
: undefined,
() => fetchAssignmentPaymentsById(engagementPaymentAssignmentIds),
{
errorRetryCount: 2,
shouldRetryOnError: true,
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
},
)
const assignmentPaymentsById = engagementPaymentAssignmentIds.length > 0
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { loadBillingDetails } from './billing-detail-requests'

describe('billing detail request queue', () => {
it('paces concurrent challenge and finance batches through the same queue', async () => {
const starts: number[] = []
let active = 0
let peak = 0
const lookup = async (value: string): Promise<string> => {
starts.push(Date.now())
active += 1
peak = Math.max(peak, active)
await Promise.resolve()
active -= 1
return value
}

const [challenges, payments] = await Promise.all([
loadBillingDetails(['challenge-1', 'challenge-2'], lookup),
loadBillingDetails(['assignment-1'], lookup),
])
expect(challenges)
.toEqual(['challenge-1', 'challenge-2'])
expect(payments)
.toEqual(['assignment-1'])
expect(peak)
.toBe(1)
expect(starts[1] - starts[0])
.toBeGreaterThanOrEqual(450)
expect(starts[2] - starts[1])
.toBeGreaterThanOrEqual(450)
})

it('releases the queue after a failed request', async () => {
await expect(loadBillingDetails(['missing'], async () => {
throw new Error('Unavailable')
})).rejects.toThrow('Unavailable')
await expect(loadBillingDetails(['next'], async value => value)).resolves.toEqual(['next'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const REQUEST_INTERVAL_MS = 500
let nextRequest: Promise<unknown> = Promise.resolve()

/**
* Schedules billing detail lookups through one shared queue so opening a large
* account cannot burst hundreds of requests at the challenge and finance APIs.
* Each request settles before the next begins, with a half-second gap. A failed
* request releases the queue; the caller decides which row fallback to display.
* @param values Unique identifiers to hydrate, in display-independent order.
* @param fetchDetail Asynchronous lookup for one identifier.
* @returns Results in input order after all lookups settle successfully.
* @throws Propagates lookup errors without blocking subsequent queued requests.
*/
export async function loadBillingDetails<T>(
values: string[],
fetchDetail: (value: string) => Promise<T>,
): Promise<T[]> {
return Promise.all(values.map(value => {
const request = nextRequest.then(() => fetchDetail(value))
nextRequest = request.catch(() => undefined)
.then(() => new Promise<void>(resolve => {
setTimeout(resolve, REQUEST_INTERVAL_MS)
}))
return request
}))
}
Loading