From 035de8e919a424229ded96965cb3f6784c2372d7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 29 Apr 2026 18:04:31 +1000 Subject: [PATCH 1/2] PM-4970: Correct BA challenge fee split What was broken BA summary divided challenge line-item amounts by markup before displaying Member Payments, so a challenge with $50 in member payments and 33% markup showed $37.59 member payments and $12.40 fee instead of $50.00 and $16.50. Root cause Challenge billing-account rows already expose the member-payment subtotal for manager/admin challenge rows, but the modal treated every non-copilot row as a ledger total that still needed markup removed. What was changed Use the raw challenge line-item amount as manager/admin member payments and calculate the challenge fee from markup. Keep the existing reverse-markup behavior for engagement ledger rows and copilot fallback responses. Any added/updated tests Updated BillingAccountLineItemsModal coverage for the PM-4970 challenge split and added an assertion that engagement rows still reverse-calculate payment and fee amounts. --- .../BillingAccountLineItemsModal.spec.tsx | 22 ++++++------ .../BillingAccountLineItemsModal.tsx | 35 ++++++++++++------- 2 files changed, 35 insertions(+), 22 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 e784634f4..7ea21a693 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -125,33 +125,31 @@ describe('BillingAccountLineItemsModal', () => { .toBe('/work/challenges/challenge%20%2F%20100') }) - it('shows member payments and challenge fees for non-copilot users', () => { + it('shows challenge member payments and calculated challenge fees for non-copilot users', () => { renderModal({ ...baseBillingAccountDetails, lockedAmounts: [ { - amount: '125.25', + amount: '50', date: '2026-02-10T00:00:00.000Z', externalId: 'challenge-100', externalName: 'Markup Challenge', externalType: 'CHALLENGE', }, ], - lockedBudget: 125.25, - markup: 0.25, - totalBudgetRemaining: 874.75, + lockedBudget: 66.5, + markup: 0.33, + totalBudgetRemaining: 933.5, }) expect(screen.getByText('Member Payments')) .toBeTruthy() expect(screen.getByText('Challenge Fee')) .toBeTruthy() - expect(screen.getByText('$100.20')) - .toBeTruthy() - expect(screen.getByText('$25.05')) - .toBeTruthy() - expect(screen.getAllByText('$125.25')) + expect(screen.getAllByText('$50.00')) .toHaveLength(1) + expect(screen.getByText('$16.50')) + .toBeTruthy() }) it('builds engagement links from assignment-backed billing rows', () => { @@ -226,6 +224,10 @@ describe('BillingAccountLineItemsModal', () => { expect(engagementLink.getAttribute('href')) .toBe('/work/projects/project%20200/engagements/engagement-300') + expect(screen.getByText('$100.00')) + .toBeTruthy() + expect(screen.getByText('$20.00')) + .toBeTruthy() expect(mockedUseFetchEngagements) .toHaveBeenLastCalledWith( 'project 200', diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 0447b7034..637395e23 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -192,8 +192,9 @@ function buildEngagementUrl(projectId: string, engagementId: string): string { * @param item Line item already mapped for the current caller role. * @returns Formatted currency or `-` when a copilot-safe amount is unavailable. * @remarks Copilot rows use member payment amounts without exposing markup. - * Other roles use the member payment amount derived from billing markup and - * show the fee separately. + * Manager/admin challenge rows use the challenge subtotal returned by the + * billing-account API, while engagement rows still derive the payment amount + * from the billing ledger total. */ function formatLineItemAmount(item: BillingAccountModalLineItem): string { return item.displayAmount === undefined @@ -223,19 +224,29 @@ function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. * @returns Member payment amount, or `undefined` for copilot rows when it cannot * be safely calculated. - * @remarks Non-copilot rows fall back to the raw amount if markup is missing so - * legacy billing-account payloads keep rendering an amount. + * @remarks Challenge budget rows already expose the member-payment subtotal + * for manager/admin users. Engagement budget rows store the billing ledger + * total, so they still need markup removed before display. Copilot rows prefer + * API-provided member-payment amounts and fall back to the legacy markup math + * only when that safe field is missing. */ function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, billingAccountDetails: BillingAccountDetails, showMemberPaymentsRemaining: boolean | undefined, ): number | undefined { - const memberPaymentAmount = item.memberPaymentAmount - ?? calculateMemberPaymentAmount( - item.amount, - billingAccountDetails.markup, - ) + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + + if (!showMemberPaymentsRemaining && item.externalType === 'CHALLENGE') { + return item.amount + } + + const memberPaymentAmount = calculateMemberPaymentAmount( + item.amount, + billingAccountDetails.markup, + ) return memberPaymentAmount !== undefined || showMemberPaymentsRemaining ? memberPaymentAmount @@ -251,9 +262,9 @@ function getLineItemMemberPaymentAmount( * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. * @remarks Copilot rows prefer the API-provided member payment amount because - * their response intentionally omits markup. Non-copilot rows derive member - * payments from the raw amount and billing-account markup, then render the fee - * in its own column. + * their response intentionally omits markup. Manager/admin challenge rows use + * the raw challenge subtotal and calculate the fee from markup; engagement rows + * derive member payments from the raw ledger amount and billing-account markup. */ function getDisplayLineItem( item: BillingAccountLineItem, From 85024660342799b898be4f721f89693fc5249d14 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 1 May 2026 10:27:51 +1000 Subject: [PATCH 2/2] PM-4970: Fix copilot challenge line item amounts --- .../BillingAccountLineItemsModal.spec.tsx | 15 ++++++++------- .../BillingAccountLineItemsModal.tsx | 18 +++++++++--------- 2 files changed, 17 insertions(+), 16 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 7ea21a693..601d6eaf2 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.spec.tsx @@ -361,31 +361,32 @@ describe('BillingAccountLineItemsModal', () => { .toBeTruthy() }) - it('shows only remaining member payments and member-payment row amounts for copilots', () => { + it('shows only remaining member payments and challenge row amounts for copilots', () => { renderModal({ ...baseBillingAccountDetails, consumedBudget: 500, lockedAmounts: [ { - amount: '125.25', + amount: '50', date: '2026-02-10T00:00:00.000Z', externalId: 'challenge-100', externalName: 'Markup Challenge', externalType: 'CHALLENGE', }, ], - lockedBudget: 250, - markup: 0.25, - totalBudgetRemaining: 250, + lockedBudget: 66.5, + markup: 0.33, + memberPaymentsRemaining: 200, + totalBudgetRemaining: 433.5, }, true) expect(screen.getByText('Remaining member payments')) .toBeTruthy() expect(screen.getByText('$200.00')) .toBeTruthy() - expect(screen.getByText('$100.20')) + expect(screen.getByText('$50.00')) .toBeTruthy() - expect(screen.queryByText('$125.25')) + expect(screen.queryByText('$37.59')) .toBeNull() expect(screen.queryByText('Consumed')) .toBeNull() diff --git a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx index 637395e23..6f7fb7599 100644 --- a/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx +++ b/src/apps/work/src/lib/components/BillingAccountLineItemsModal/BillingAccountLineItemsModal.tsx @@ -225,10 +225,10 @@ function formatLineItemChallengeFee(item: BillingAccountModalLineItem): string { * @returns Member payment amount, or `undefined` for copilot rows when it cannot * be safely calculated. * @remarks Challenge budget rows already expose the member-payment subtotal - * for manager/admin users. Engagement budget rows store the billing ledger - * total, so they still need markup removed before display. Copilot rows prefer - * API-provided member-payment amounts and fall back to the legacy markup math - * only when that safe field is missing. + * for every caller. Engagement budget rows store the billing ledger total, so + * they still need markup removed before display. Copilot engagement rows prefer + * API-provided member-payment amounts and fall back to markup math only when + * that safe field is missing. */ function getLineItemMemberPaymentAmount( item: BillingAccountLineItem, @@ -239,7 +239,7 @@ function getLineItemMemberPaymentAmount( return item.memberPaymentAmount } - if (!showMemberPaymentsRemaining && item.externalType === 'CHALLENGE') { + if (item.externalType === 'CHALLENGE') { return item.amount } @@ -261,10 +261,10 @@ function getLineItemMemberPaymentAmount( * @param showMemberPaymentsRemaining Whether the caller needs the copilot-safe view. * @returns A line item with `displayAmount` set to the visible member-payment * amount and, for non-copilots, `challengeFeeAmount` set to the billing markup fee. - * @remarks Copilot rows prefer the API-provided member payment amount because - * their response intentionally omits markup. Manager/admin challenge rows use - * the raw challenge subtotal and calculate the fee from markup; engagement rows - * derive member payments from the raw ledger amount and billing-account markup. + * @remarks Challenge rows use the raw member-payment subtotal for all callers. + * Non-copilot challenge rows also calculate the hidden fee from markup. + * Engagement rows derive member payments from the raw ledger amount and + * billing-account markup. */ function getDisplayLineItem( item: BillingAccountLineItem,