diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx index 67c5d603a..5f60627fd 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -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: [ diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index df2a01675..46a27b821 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -51,6 +51,7 @@ interface BillingAccountModalLineItem extends BillingAccountLineItem { interface EngagementPaymentSplit { challengeFee?: number + date?: string paymentAmount: number } @@ -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. * @@ -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. * @@ -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) } /**