diff --git a/src/apps/sales/README.md b/src/apps/sales/README.md
index 3bffbc546..14f0649ef 100644
--- a/src/apps/sales/README.md
+++ b/src/apps/sales/README.md
@@ -17,6 +17,22 @@ opportunity description first, followed by the customer, SMU, close date and
stage when Salesforce provides them, plus a link to the record. The popup closes
with its Close button or the X icon; obsolete lookups are aborted.
+When the opportunity has a local billing account, the popup also shows its
+current ID/name, a **View project … in Work** link to
+`{WORK_APP}/projects/{projectId}/challenges`, and a **Past billing accounts** list
+with each account's ID/name. The Projects API resolves the account from
+`BillingAccount.opportunity`, the project from `Project.billingAccountId`, and
+`relatedBillingAccounts` from all unique billing account IDs referenced by that
+project's challenges (all statuses, including inactive accounts). The popup
+excludes the current account from the past-account list; the response retains it
+when a challenge references it. An empty past list displays “No past billing
+accounts found.” Missing names display “Name unavailable” alongside the ID.
+
+All new fields are hidden when `billingAccount` is null or absent. An account
+without a matching project still displays its details, with no Work link.
+Links use the active environment's Work host. Deploy the Billing Accounts API
+`opportunity` filter, then the Projects API response expansion, before the UI.
+
## Dashboard layout (PM-6392)
The page reads as an executive dashboard rather than one long column:
diff --git a/src/apps/sales/src/OpportunityModal.module.scss b/src/apps/sales/src/OpportunityModal.module.scss
index 37b3c02dc..264794bce 100644
--- a/src/apps/sales/src/OpportunityModal.module.scss
+++ b/src/apps/sales/src/OpportunityModal.module.scss
@@ -51,6 +51,33 @@
overflow-wrap: anywhere;
}
+.billingDetails {
+ display: grid;
+ gap: 16px;
+ margin-top: 24px;
+}
+
+.accountId {
+ display: block;
+ font-size: 12px;
+ color: $tc-2026-muted;
+}
+
+.accountList {
+ list-style: disc;
+ padding-left: 20px;
+
+ li + li {
+ margin-top: 8px;
+ }
+}
+
+.projectLink {
+ font-weight: 700;
+ color: $tc-2026-teal;
+ text-decoration: underline;
+}
+
.link {
display: inline-block;
margin-top: 24px;
diff --git a/src/apps/sales/src/OpportunityModal.spec.tsx b/src/apps/sales/src/OpportunityModal.spec.tsx
new file mode 100644
index 000000000..607d11d7b
--- /dev/null
+++ b/src/apps/sales/src/OpportunityModal.spec.tsx
@@ -0,0 +1,132 @@
+/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
+import '@testing-library/jest-dom'
+import { render, screen, within } from '@testing-library/react'
+import { ReactNode } from 'react'
+
+import OpportunityModal from './OpportunityModal'
+import { fetchOpportunity, SalesOpportunity } from './opportunity.service'
+
+jest.mock('~/config', () => ({
+ EnvironmentConfig: { URLS: { WORK_APP: 'https://work.topcoder-dev.com/' } },
+}), { virtual: true })
+
+jest.mock('./opportunity.service', () => ({
+ fetchOpportunity: jest.fn(),
+ opportunityErrorMessage: () => 'Unable to load opportunity details.',
+}))
+
+jest.mock('~/libs/ui', () => ({
+ BaseModal: (props: { children: ReactNode; open: boolean; title: ReactNode }) => (
+ props.open ? (
+
+
{props.title}
+ {props.children}
+
+ ) : undefined
+ ),
+ Button: () => ,
+ LoadingSpinner: () => Loading,
+}), { virtual: true })
+
+const fetchDetails = fetchOpportunity as jest.MockedFunction
+const opportunity: SalesOpportunity = {
+ billingAccount: { id: '22', name: 'Current contract' },
+ description: 'Example description',
+ id: '006UN00000XamntYAB',
+ name: 'Example opportunity',
+ projectId: '123',
+ relatedBillingAccounts: [
+ { id: '3', name: 'Original contract' },
+ { id: '11', name: 'Extension' },
+ { id: '22', name: 'Current contract' },
+ { id: '99' },
+ ],
+ url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
+}
+
+/**
+ * Opens the popup with a mocked opportunity response for billing behavior tests.
+ * @param details Opportunity data, including nullable or absent billing context.
+ * @returns Resolves after the opportunity has loaded in the rendered modal.
+ * @throws Testing Library errors if the expected description does not render.
+ */
+async function openOpportunity(details: SalesOpportunity): Promise {
+ fetchDetails.mockResolvedValue(details)
+ render()
+ await screen.findByText('Example description')
+}
+
+describe('OpportunityModal billing context', () => {
+ beforeEach(() => jest.clearAllMocks())
+
+ it('shows the current account, Work challenges link, and only past accounts in the list', async () => {
+ await openOpportunity(opportunity)
+ expect(screen.getByText('Current contract'))
+ .toBeInTheDocument()
+ expect(screen.getByText('ID: 22'))
+ .toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'View project 123 in Work' }))
+ .toHaveAttribute('href', 'https://work.topcoder-dev.com/projects/123/challenges')
+ expect(screen.getByRole('link', { name: 'View project 123 in Work' }))
+ .toHaveAttribute('rel', 'noopener noreferrer')
+ const list = screen.getByRole('list')
+ expect(within(list)
+ .getAllByRole('listitem'))
+ .toHaveLength(3)
+ expect(within(list)
+ .getByText('Original contract'))
+ .toBeInTheDocument()
+ expect(within(list)
+ .getByText('Extension'))
+ .toBeInTheDocument()
+ expect(within(list)
+ .getByText('Name unavailable'))
+ .toBeInTheDocument()
+ expect(within(list)
+ .getByText('ID: 99'))
+ .toBeInTheDocument()
+ expect(within(list)
+ .queryByText('Current contract')).not.toBeInTheDocument()
+ })
+
+ // The API sends JSON null, while older responses omit the new field.
+ // eslint-disable-next-line unicorn/no-null
+ it.each([null, undefined])('hides every new field without an associated billing account (%s)', async account => {
+ await openOpportunity({ ...opportunity, billingAccount: account })
+ expect(screen.queryByText('Current billing account')).not.toBeInTheDocument()
+ expect(screen.queryByText('Past billing accounts')).not.toBeInTheDocument()
+ expect(screen.queryByText('Work project')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('list')).not.toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'View in Salesforce' }))
+ .toBeInTheDocument()
+ })
+
+ it('keeps the account visible when no matching project exists', async () => {
+ // eslint-disable-next-line unicorn/no-null -- The API sends a null project ID when no project matches.
+ await openOpportunity({ ...opportunity, projectId: null, relatedBillingAccounts: [] })
+ expect(screen.getByText('ID: 22'))
+ .toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
+ expect(screen.getByText('No past billing accounts found.'))
+ .toBeInTheDocument()
+ })
+
+ it('shows an empty past list when only the current account was used by challenges', async () => {
+ await openOpportunity({ ...opportunity, relatedBillingAccounts: [{ id: '22', name: 'Current contract' }] })
+ expect(screen.getAllByText('Current contract'))
+ .toHaveLength(1)
+ expect(screen.getByText('No past billing accounts found.'))
+ .toBeInTheDocument()
+ })
+
+ it('does not construct a Work link for an invalid project ID', async () => {
+ await openOpportunity({ ...opportunity, projectId: '../other' })
+ expect(screen.queryByRole('link', { name: /in Work/ })).not.toBeInTheDocument()
+ })
+})
diff --git a/src/apps/sales/src/OpportunityModal.tsx b/src/apps/sales/src/OpportunityModal.tsx
index cfdf38973..65bb747d1 100644
--- a/src/apps/sales/src/OpportunityModal.tsx
+++ b/src/apps/sales/src/OpportunityModal.tsx
@@ -1,4 +1,3 @@
-/* The modal body renders only the opportunity fields the report already exposes. */
import { FC, useEffect, useState } from 'react'
import { BaseModal, Button, LoadingSpinner } from '~/libs/ui'
@@ -8,6 +7,7 @@ import {
opportunityErrorMessage,
SalesOpportunity,
} from './opportunity.service'
+import { workProjectUrl } from './sales.utils'
import styles from './OpportunityModal.module.scss'
export interface OpportunityModalProps {
@@ -26,6 +26,8 @@ type ModalState =
/**
* Shows the details of one Salesforce opportunity, starting with its description.
+ * When a billing account exists, also shows its ID/name, the Work challenges
+ * link, and past accounts used by the project, excluding the current account.
* @param props The opportunity to load, whether the popup is open, and the close handler.
* @returns A dialog closed with either the Close button or the X icon.
* @throws Does not throw request failures; shows the sanitized message inline.
@@ -56,6 +58,10 @@ export const OpportunityModal: FC = props => {
}, [props.open, props.opportunityId])
const opportunity = state.status === 'ready' ? state.opportunity : undefined
+ const billingAccount = opportunity?.billingAccount
+ const projectUrl = billingAccount ? workProjectUrl(opportunity?.projectId) : undefined
+ const pastBillingAccounts = (opportunity?.relatedBillingAccounts ?? [])
+ .filter(account => account.id !== billingAccount?.id)
const details: Array<{ label: string; value: string | undefined }> = [
{ label: 'Customer', value: opportunity?.customer },
{ label: 'SMU', value: opportunity?.reportingSmu },
@@ -100,6 +106,60 @@ export const OpportunityModal: FC = props => {
)}
+ {billingAccount && (
+