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..601d6eaf2 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', @@ -359,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 0447b7034..6f7fb7599 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 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, billingAccountDetails: BillingAccountDetails, showMemberPaymentsRemaining: boolean | undefined, ): number | undefined { - const memberPaymentAmount = item.memberPaymentAmount - ?? calculateMemberPaymentAmount( - item.amount, - billingAccountDetails.markup, - ) + if (item.memberPaymentAmount !== undefined) { + return item.memberPaymentAmount + } + + if (item.externalType === 'CHALLENGE') { + return item.amount + } + + const memberPaymentAmount = calculateMemberPaymentAmount( + item.amount, + billingAccountDetails.markup, + ) return memberPaymentAmount !== undefined || showMemberPaymentsRemaining ? memberPaymentAmount @@ -250,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. Non-copilot rows derive member - * payments from the raw amount and billing-account markup, then render the fee - * in its own column. + * @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,