From 226e91e477be9d2d423cf1371ebb9b736d642920 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Jul 2026 11:23:46 +1000 Subject: [PATCH] PM-5507: Match repeated engagement payments by date What was broken Billing account detail rows for repeated engagement assignment payments could display member payment and challenge fee values derived from the current billing account markup instead of the actual finance payment split. Root cause The modal fetched finance payments by assignment id, but when several payments shared the same assignment and amount, the split matcher could not identify the specific finance row for the consumed billing row. It then fell back to markup-derived math, which drifted from persisted payment amounts when markup changed or finance did not expose a fee. What was changed Added day-level finance payment date matching before the existing exact amount and aggregate fallbacks. When a single finance payment date matches the billing ledger row, the modal uses that payment amount and infers the challenge fee from the ledger total if needed. Any added/updated tests Added a BillingAccountLineItemsModal regression test that covers repeated engagement payments for the same assignment and verifies the modal shows the actual finance payment amount and inferred fee instead of the markup-derived fallback. --- .../BillingAccountLineItemsModal.spec.tsx | 54 ++++++ .../BillingAccountLineItemsModal.tsx | 156 ++++++++++++++---- 2 files changed, 176 insertions(+), 34 deletions(-) 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) } /**