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

it('uses the line item date to disambiguate repeated finance splits', async () => {
mockedFetchAssignmentPaymentSplits.mockResolvedValue([
{
billingAccountId: '80001063',
challengeFee: '386.94',
createdAt: '2026-06-01T07:37:17.049Z',
details: [
{
billingAccount: '80001063',
totalAmount: 544.99,
},
],
paymentId: 'be710af2-a4ab-44ed-b414-32b1c82415bd',
},
{
billingAccountId: '80001063',
challengeFee: '386.94',
createdAt: '2026-06-15T13:28:18.662Z',
details: [
{
billingAccount: '80001063',
totalAmount: 544.99,
},
],
paymentId: '8fbb836d-3d6b-4e19-a4cc-871e0e1bc12d',
},
])

renderModal({
...baseBillingAccountDetails,
consumedAmounts: [
{
amount: '931.93',
date: '2026-06-15T13:28:18.662Z',
externalId: 'assignment-repeated',
externalName: 'Repeated Engagement',
externalType: 'ENGAGEMENT',
memberPaymentAmount: '544.19',
},
],
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-repeated')
})

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 @@ -54,6 +54,16 @@ interface EngagementPaymentSplit {
paymentAmount: number
}

interface EngagementPaymentSplitMatch {
payment: AssignmentPayment
split: EngagementPaymentSplit
}

interface EngagementPaymentSplitMatchResult {
matchCount: number
split?: EngagementPaymentSplit
}

const ENGAGEMENT_ASSIGNMENT_FILTERS = {
includePrivate: true,
}
Expand Down Expand Up @@ -100,6 +110,83 @@ function formatDate(dateString: string): string {
return `${year}-${month}-${day}`
}

/**
* Normalizes timestamp-like values to a UTC date key.
*
* @param value Raw timestamp or date value from billing-account or finance data.
* @returns `YYYY-MM-DD` date key, or `undefined` when the value is blank or invalid.
* @remarks Used only to disambiguate repeated engagement payments with the
* same ledger amount.
*/
function getDateKey(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined
}

const normalizedValue = value instanceof Date
? value
: String(value)
.trim()

if (!normalizedValue) {
return undefined
}

const isoDateMatch = String(normalizedValue)
.match(/^(\d{4}-\d{2}-\d{2})/)

if (isoDateMatch) {
return isoDateMatch[1]
}

const date = new Date(normalizedValue)

if (Number.isNaN(date.getTime())) {
return undefined
}

return date.toISOString()
.slice(0, 10)
}

/**
* Collects date keys carried by a finance payment.
*
* @param payment Finance payment row.
* @returns Unique date keys from payment-level and detail-level timestamps.
* @remarks Finance responses have varied over time, so both top-level and
* detail-level date fields are considered when matching billing-account rows.
*/
function getPaymentDateKeys(payment: AssignmentPayment): string[] {
return Array.from(new Set([
getDateKey(payment.createdAt),
getDateKey(payment.updatedAt),
getDateKey(payment.datePaid),
getDateKey(payment.releaseDate),
...(payment.details || []).flatMap(detail => [
getDateKey(detail.datePaid),
getDateKey(detail.releaseDate),
]),
].filter((dateKey): dateKey is string => !!dateKey)))
}

/**
* Checks whether a finance payment date matches a billing-account line item.
*
* @param payment Finance payment row.
* @param item Billing-account line item being reconciled.
* @returns `true` when both records carry the same UTC date.
*/
function paymentDateMatchesLineItem(
payment: AssignmentPayment,
item: BillingAccountLineItem,
): boolean {
const lineItemDateKey = getDateKey(item.date)

return !!lineItemDateKey && getPaymentDateKeys(payment)
.includes(lineItemDateKey)
}

/**
* Resolves the amount used when sorting modal line items.
*
Expand Down Expand Up @@ -252,15 +339,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 @@ -572,24 +679,26 @@ function getEngagementFinancePaymentSplit(
return undefined
}

const financeSplits = filterPaymentsForBillingAccount(
const financeSplitMatches = filterPaymentsForBillingAccount(
assignmentPayments,
billingAccountDetails.id,
)
.map(getFinancePaymentSplit)
.filter((split): split is EngagementPaymentSplit => !!split)
.map(getFinancePaymentSplitMatch)
.filter((match): match is EngagementPaymentSplitMatch => !!match)

const financeSplits = financeSplitMatches.map(match => match.split)

if (financeSplits.length === 0) {
return undefined
}

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

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

const aggregateSplit = getAggregatePaymentSplit(financeSplits)
Expand All @@ -610,7 +719,7 @@ function getEngagementFinancePaymentSplit(
&& aggregateSplit.paymentAmount <= item.amount
&& (
financeSplits.length === 1
|| matchingPaymentSplits.length === 0
|| matchingPaymentSplitResult.matchCount === 0
)
) {
return {
Expand Down
4 changes: 4 additions & 0 deletions src/apps/work/src/lib/models/Engagement.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,24 @@ export interface AssignmentPayment {
createdBy?: string
createdByHandle?: string
createdAt?: string
datePaid?: string
description?: string
details?: Array<{
amount?: number
billingAccount?: number | string
billingAccountName?: string
challengeFee?: number | string
datePaid?: string
grossAmount?: number
hoursWorked?: number | string
releaseDate?: string
totalAmount?: number
}>
hoursWorked?: number | string
id?: number | string
paymentId?: number | string
paymentAmount?: number | string
releaseDate?: string
status?: string
title?: string
updatedAt?: string
Expand Down
Loading