From 75d71e960bcb63a3d3aecc46f760ba94ad057e31 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Jul 2026 15:02:15 +1000 Subject: [PATCH] Fixes for the billing account details popup in PM-5507 --- .../BillingAccountLineItemsModal.spec.tsx | 59 ++++++++ .../BillingAccountLineItemsModal.tsx | 129 ++++++++++++++++-- .../work/src/lib/models/Engagement.model.ts | 4 + 3 files changed, 182 insertions(+), 10 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..9fd3de0e0 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,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: [ diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index df2a01675..1c792707f 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -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, } @@ -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. * @@ -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. * @@ -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) @@ -610,7 +719,7 @@ function getEngagementFinancePaymentSplit( && aggregateSplit.paymentAmount <= item.amount && ( financeSplits.length === 1 - || matchingPaymentSplits.length === 0 + || matchingPaymentSplitResult.matchCount === 0 ) ) { return { diff --git a/src/apps/work/src/lib/models/Engagement.model.ts b/src/apps/work/src/lib/models/Engagement.model.ts index 5d9809b94..337115630 100644 --- a/src/apps/work/src/lib/models/Engagement.model.ts +++ b/src/apps/work/src/lib/models/Engagement.model.ts @@ -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