Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading