Skip to content
Draft
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 @@ -457,6 +457,60 @@ describe('BillingAccountLineItemsModal', () => {
.toHaveBeenCalledWith('assignment-5245')
})

it('matches repeated engagement payment splits by payment date before deriving markup', async () => {
mockedFetchAssignmentPaymentSplits.mockResolvedValue([
{
createdAt: '2026-06-01T17:07:17.000Z',
details: [
{
billingAccount: '80001063',
totalAmount: 544.99,
},
],
paymentId: 'june-1-payment',
},
{
createdAt: '2026-06-15T22:58:18.000Z',
details: [
{
billingAccount: '80001063',
totalAmount: 544.99,
},
],
paymentId: 'june-15-payment',
},
])

renderModal({
...baseBillingAccountDetails,
consumedAmounts: [
{
amount: '931.93',
date: '2026-06-15T22:58:20.000Z',
externalId: 'assignment-5507',
externalName: 'Wipro - UHG - Power BI resources',
externalType: 'ENGAGEMENT',
},
],
consumedBudget: 931.93,
markup: 0.7125,
totalBudgetRemaining: 68.07,
})

await waitFor(() => {
expect(screen.getByText('$544.99'))
.toBeTruthy()
expect(screen.getByText('$386.94'))
.toBeTruthy()
})
expect(screen.queryByText('$544.19'))
.toBeNull()
expect(screen.queryByText('$387.74'))
.toBeNull()
expect(mockedFetchAssignmentPaymentSplits)
.toHaveBeenCalledWith('assignment-5507')
})

it('builds engagement links from assignment-backed billing rows for copilot views', () => {
mockedUseFetchEngagements.mockReturnValue({
engagements: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ interface BillingAccountModalLineItem extends BillingAccountLineItem {

interface EngagementPaymentSplit {
challengeFee?: number
date?: string
paymentAmount: number
}

Expand Down Expand Up @@ -252,15 +253,35 @@ function getFinancePaymentSplit(payment: AssignmentPayment): EngagementPaymentSp
}

const challengeFee = getPaymentChallengeFee(payment)
const paymentDate = getFinancePaymentDate(payment)

return {
challengeFee: challengeFee === undefined
? undefined
: roundCurrencyAmount(challengeFee),
...(paymentDate ? { date: paymentDate } : {}),
paymentAmount: roundCurrencyAmount(paymentAmount),
}
}

/**
* Resolves the finance payment date used to match one billing ledger row.
*
* @param payment Finance payment row.
* @returns UTC date string at day precision, or `undefined` when no payment date is available.
* @remarks Engagement assignments can have repeated payments with the same
* amount. Matching by date lets the modal use the persisted payment amount for
* the specific consumed row instead of falling back to current billing markup.
*/
function getFinancePaymentDate(payment: AssignmentPayment): string | undefined {
const paymentDate = normalizeRouteId(payment.createdAt)
|| normalizeRouteId(payment.updatedAt)

return paymentDate
? formatDate(paymentDate)
: undefined
}

/**
* Adds finance payment splits together for aggregate billing-account rows.
*
Expand Down Expand Up @@ -288,6 +309,103 @@ function getAggregatePaymentSplit(
}
}

/**
* Finds the single finance split whose payment date matches a billing row.
*
* @param item Billing-account engagement row.
* @param splits Finance payment splits for the row assignment and billing account.
* @returns Date-matched split with an inferred fee when needed, or `undefined`
* when the date is ambiguous or incompatible with the billing amount.
* @remarks This is used before aggregate matching because repeated assignment
* payments can share an external id and amount while representing different
* ledger rows.
*/
function getDateMatchedPaymentSplit(
item: BillingAccountLineItem,
splits: EngagementPaymentSplit[],
): EngagementPaymentSplit | undefined {
const lineItemDate = formatDate(item.date)
const dateMatchedSplits = splits.filter(split => split.date === lineItemDate)

if (dateMatchedSplits.length !== 1) {
return undefined
}

const [dateMatchedSplit] = dateMatchedSplits

if (dateMatchedSplit.paymentAmount > item.amount) {
return undefined
}

if (
dateMatchedSplit.challengeFee !== undefined
&& !currencyAmountsMatch(
dateMatchedSplit.paymentAmount + dateMatchedSplit.challengeFee,
item.amount,
)
) {
return undefined
}

return {
...dateMatchedSplit,
challengeFee: dateMatchedSplit.challengeFee
?? roundCurrencyAmount(item.amount - dateMatchedSplit.paymentAmount),
}
}

/**
* Matches finance splits to a billing row by exact amount or safe aggregate fallback.
*
* @param item Billing-account engagement row.
* @param splits Finance payment splits for the row assignment and billing account.
* @returns Matching split when the finance values reconcile to the billing row.
* @remarks Used after date matching so existing single-payment and aggregate
* reconciliation behavior remains unchanged.
*/
function getAmountMatchedPaymentSplit(
item: BillingAccountLineItem,
splits: EngagementPaymentSplit[],
): EngagementPaymentSplit | undefined {
const matchingPaymentSplits = splits.filter(split => (
split.challengeFee !== undefined
&& currencyAmountsMatch(split.paymentAmount + split.challengeFee, item.amount)
))

if (matchingPaymentSplits.length === 1) {
return matchingPaymentSplits[0]
}

const aggregateSplit = getAggregatePaymentSplit(splits)

if (
aggregateSplit
&& aggregateSplit.challengeFee !== undefined
&& currencyAmountsMatch(
aggregateSplit.paymentAmount + aggregateSplit.challengeFee,
item.amount,
)
) {
return aggregateSplit
}

if (
aggregateSplit
&& aggregateSplit.paymentAmount <= item.amount
&& (
splits.length === 1
|| matchingPaymentSplits.length === 0
)
) {
return {
challengeFee: roundCurrencyAmount(item.amount - aggregateSplit.paymentAmount),
paymentAmount: aggregateSplit.paymentAmount,
}
}

return undefined
}

/**
* Builds an absolute work-app path with the configured root route prefix.
*
Expand Down Expand Up @@ -583,43 +701,13 @@ function getEngagementFinancePaymentSplit(
return undefined
}

const matchingPaymentSplits = financeSplits.filter(split => (
split.challengeFee !== undefined
&& currencyAmountsMatch(split.paymentAmount + split.challengeFee, item.amount)
))

if (matchingPaymentSplits.length === 1) {
return matchingPaymentSplits[0]
}

const aggregateSplit = getAggregatePaymentSplit(financeSplits)
const dateMatchedPaymentSplit = getDateMatchedPaymentSplit(item, financeSplits)

if (
aggregateSplit
&& aggregateSplit.challengeFee !== undefined
&& currencyAmountsMatch(
aggregateSplit.paymentAmount + aggregateSplit.challengeFee,
item.amount,
)
) {
return aggregateSplit
if (dateMatchedPaymentSplit) {
return dateMatchedPaymentSplit
}

if (
aggregateSplit
&& aggregateSplit.paymentAmount <= item.amount
&& (
financeSplits.length === 1
|| matchingPaymentSplits.length === 0
)
) {
return {
challengeFee: roundCurrencyAmount(item.amount - aggregateSplit.paymentAmount),
paymentAmount: aggregateSplit.paymentAmount,
}
}

return undefined
return getAmountMatchedPaymentSplit(item, financeSplits)
}

/**
Expand Down
Loading