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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,36 @@ Talent Manager note:
- `Talent Manager` and `Topcoder Talent Manager` callers also receive the elevated per-member response from `GET /v6/projects/:projectId/permissions`, which is used to provision challenge-related actions in Work Manager.
- Updating `billingAccountId` is restricted to human administrators and project members whose role on that project is `manager` (`Full Access`).

### Salesforce opportunity details

`GET /v6/projects/salesforce/opportunities/:opportunityId` (manager-tier and
Talent Manager JWT roles) returns the existing Salesforce fields plus:

- `billingAccount`: `{ id: string, name?: string }`, or `null` without a local account.
- `projectId`: string ID of a non-deleted project whose current `billingAccountId`
matches the opportunity account, or `null` without a matching project.
- `relatedBillingAccounts`: unique `{ id, name? }` summaries from every challenge
associated with that project, sorted numerically. This includes inactive
accounts and every challenge status. The current account appears in this array
only when a challenge uses it. Missing historical metadata retains the ID.

The account lookup uses the Billing Accounts API's `opportunity` filter against
`BillingAccount.opportunity`, accepting both 15- and 18-character Salesforce IDs.
If several accounts/projects match, the most recently updated non-deleted project
wins (then highest project ID). Without a project, the highest matching account
ID is returned. Without an account, the project is `null` and history is `[]`.
A billing/project/challenge lookup failure returns a sanitized 502, rather than
claiming that no account/history exists. Historical metadata uses the existing
Billing Accounts API/Salesforce fallback, with at most five concurrent requests.
Only IDs and names are exposed here; no financial or client metadata is added.

Deploy the Billing Accounts API opportunity filter first, then Projects API,
then the UI. Configure `BILLING_ACCOUNTS_API_URL`, its existing M2M read access,
and `CHALLENGES_DB_URL`. No new environment variable or schema migration is
required. History reflects references currently stored on challenges; deleted or overwritten references cannot be reconstructed.
The Sales popup builds an environment-specific Work link ending in
`/projects/{projectId}/challenges` and hides all new fields without an account.

### Members

| Method | Path | Auth | Description |
Expand Down
40 changes: 39 additions & 1 deletion src/api/salesforce/dto/salesforce-opportunity-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

/** ID and name of a current or historical opportunity billing account. */
export class SalesforceOpportunityBillingAccountDto {
@ApiProperty({
description: 'Topcoder billing account ID.',
example: '80001063',
})
id: string;

@ApiPropertyOptional({
description: 'Account name, omitted if metadata cannot be resolved.',
})
name?: string;
}

/**
* Salesforce opportunity fields used to populate project details.
* Salesforce opportunity fields and project billing context used by Work and Sales.
*
* Only read-only, non-sensitive opportunity attributes are exposed. The
* `smu`/`smuOther` pair is already mapped onto the SMU options accepted by
Expand Down Expand Up @@ -62,6 +76,30 @@ export class SalesforceOpportunityResponseDto {
@ApiPropertyOptional({ description: 'Opportunity stage name.' })
stageName?: string;

@ApiProperty({
description:
'Current opportunity billing account, or null when no account is associated.',
type: SalesforceOpportunityBillingAccountDto,
nullable: true,
})
billingAccount: SalesforceOpportunityBillingAccountDto | null;

@ApiProperty({
description:
'Project whose current billingAccountId matches the opportunity account; null when none exists.',
type: String,
nullable: true,
example: '12345',
})
projectId: string | null;

@ApiProperty({
description:
'Unique accounts referenced by all project challenges, including the current account only if used by a challenge. Sorted by numeric ID; unavailable metadata retains the ID.',
type: [SalesforceOpportunityBillingAccountDto],
})
relatedBillingAccounts: SalesforceOpportunityBillingAccountDto[];

@ApiProperty({
description: 'Deep link to the opportunity record in Salesforce.',
example: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
Expand Down
12 changes: 7 additions & 5 deletions src/api/salesforce/salesforce-opportunity.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const OPPORTUNITY_ROLES = [
* Read-only Salesforce opportunity endpoints.
*
* Used by the Work app to populate project details from an opportunity, and by
* the Sales app to show an opportunity description. Responses are never cached
* the Sales app to show opportunity details and project billing history. Responses are never cached
* by intermediaries because Salesforce remains the source of truth.
*/
@ApiTags('Salesforce')
Expand All @@ -50,18 +50,19 @@ export class SalesforceOpportunityController {
constructor(private readonly opportunities: SalesforceOpportunityService) {}

/**
* Returns a single Salesforce opportunity by record id.
* Returns a Salesforce opportunity with its current account and project history.
*
* @param opportunityId 15 or 18 character Salesforce opportunity id
* @returns the opportunity attributes used by Work and Sales
* @returns Opportunity attributes, current account, project ID, and related accounts.
* @throws Propagates validation, not-found, configuration, and upstream errors from the service.
*/
@Get(':opportunityId')
@Roles(...OPPORTUNITY_ROLES)
@Header('Cache-Control', 'private, no-store')
@ApiOperation({
summary: 'Get a Salesforce opportunity',
description:
'Reads Subcontracting End Customer, Reporting SMU, Close Date and Description for an opportunity. Read-only; nothing is written back to Salesforce.',
'Reads opportunity details, the local billing account and matching project, and unique billing accounts used by the project challenges. Read-only; nothing is written back to Salesforce.',
})
@ApiParam({
name: 'opportunityId',
Expand All @@ -72,7 +73,8 @@ export class SalesforceOpportunityController {
@ApiBadRequestResponse({ description: 'Malformed opportunity id.' })
@ApiNotFoundResponse({ description: 'No opportunity exists for that ID.' })
@ApiBadGatewayResponse({
description: 'Salesforce is unavailable or returned an invalid response.',
description:
'Salesforce, billing accounts, or project billing history could not be loaded.',
})
@ApiServiceUnavailableResponse({
description: 'Server Salesforce configuration is missing or invalid.',
Expand Down
159 changes: 157 additions & 2 deletions src/api/salesforce/salesforce-opportunity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadGatewayException,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { SalesforceOpportunityService } from './salesforce-opportunity.service';
import { SalesforceClient } from './salesforce.client';

import { getChallengesPrismaClient } from 'src/shared/global/external-prisma.client';
import { PrismaService } from 'src/shared/modules/global/prisma.service';
import { BillingAccountService } from 'src/shared/services/billingAccount.service';

jest.mock('src/shared/global/external-prisma.client', () => ({
getChallengesPrismaClient: jest.fn(),
}));

describe('SalesforceOpportunityService', () => {
const findProject = jest.fn();
const findChallenges = jest.fn();
const findAccounts = jest.fn();
const accountMetadata = jest.fn();
beforeEach(() => {
findProject.mockReset().mockResolvedValue(null);
findChallenges.mockReset().mockResolvedValue([]);
findAccounts.mockReset().mockResolvedValue([]);
accountMetadata.mockReset().mockResolvedValue({});
(getChallengesPrismaClient as jest.Mock).mockReturnValue({
challenge: { findMany: findChallenges },
});
});
const record = {
Id: '006UN00000XamntYAB',
Name: 'EMEA - Amazon Web Services - PS BFSI',
Expand All @@ -13,6 +38,12 @@ describe('SalesforceOpportunityService', () => {
Subcontracting_End_Customer__r: { Name: 'Novartis Pharmaceuticals' },
};

/**
* Builds the opportunity service with isolated upstream dependencies.
* @param records Salesforce records; defaults to a complete opportunity.
* @returns Service and SOQL mock for mapping and billing assertions.
* @throws Does not throw.
*/
function build(records: unknown[] = [record]): {
service: SalesforceOpportunityService;
query: jest.Mock;
Expand All @@ -22,7 +53,17 @@ describe('SalesforceOpportunityService', () => {
query,
instanceOrigin: () => 'https://topcoder.my.salesforce.com',
} as unknown as SalesforceClient;
return { service: new SalesforceOpportunityService(client), query };
return {
service: new SalesforceOpportunityService(
client,
{ project: { findFirst: findProject } } as unknown as PrismaService,
{
getBillingAccountsForOpportunity: findAccounts,
getBillingAccountsByIds: accountMetadata,
} as unknown as BillingAccountService,
),
query,
};
}

it('maps the opportunity onto project detail fields', async () => {
Expand All @@ -40,6 +81,9 @@ describe('SalesforceOpportunityService', () => {
closeDate: '2026-07-31',
stageName: 'Qualification',
url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
billingAccount: null,
projectId: null,
relatedBillingAccounts: [],
});
expect(query).toHaveBeenCalledWith(
expect.stringContaining("WHERE Id = '006UN00000XamntYAB'"),
Expand Down Expand Up @@ -77,6 +121,9 @@ describe('SalesforceOpportunityService', () => {
closeDate: undefined,
stageName: undefined,
url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB',
billingAccount: null,
projectId: null,
relatedBillingAccounts: [],
},
);
});
Expand Down Expand Up @@ -111,4 +158,112 @@ describe('SalesforceOpportunityService', () => {
service.getOpportunity('006UN00000XamntYAB'),
).rejects.toBeInstanceOf(NotFoundException);
});

it('skips project and challenge lookups when the opportunity has no account', async () => {
const { service } = build();
await expect(service.getOpportunity(record.Id)).resolves.toMatchObject({
billingAccount: null,
projectId: null,
relatedBillingAccounts: [],
});
expect(findProject).not.toHaveBeenCalled();
expect(findChallenges).not.toHaveBeenCalled();
expect(accountMetadata).not.toHaveBeenCalled();
});

it('keeps the account when no non-deleted project currently uses it', async () => {
findAccounts.mockResolvedValue([{ id: '22', name: 'Current account' }]);
const { service } = build();
await expect(service.getOpportunity(record.Id)).resolves.toMatchObject({
billingAccount: { id: '22', name: 'Current account' },
projectId: null,
relatedBillingAccounts: [],
});
expect(findChallenges).not.toHaveBeenCalled();
});

it('resolves all unique challenge accounts and retains IDs with missing metadata', async () => {
findAccounts.mockResolvedValue([{ id: '22', name: 'Current' }]);
findProject.mockResolvedValue({
id: BigInt(123),
billingAccountId: BigInt(22),
});
findChallenges.mockResolvedValue([
...['22', '11', ' 0011 ', '3', '99', '0', '12bad', '', null].map(
(billingAccountId) => ({ billingRecord: { billingAccountId } }),
),
{ billingRecord: null },
]);
accountMetadata.mockResolvedValue({
'3': { name: 'First', active: false, markup: 0.2 },
'11': { name: 'Previous', active: false },
});
const { service } = build();
const response = await service.getOpportunity(record.Id);
expect(response).toMatchObject({
billingAccount: { id: '22', name: 'Current' },
projectId: '123',
});
expect(response.relatedBillingAccounts).toEqual([
{ id: '3', name: 'First' },
{ id: '11', name: 'Previous' },
{ id: '22', name: 'Current' },
{ id: '99' },
]);
expect(findAccounts).toHaveBeenCalledWith(record.Id);
expect(findProject).toHaveBeenCalledWith({
where: { billingAccountId: { in: [BigInt(22)] }, deletedAt: null },
select: { id: true, billingAccountId: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
});
expect(findChallenges).toHaveBeenCalledWith({
where: { projectId: 123 },
select: { billingRecord: { select: { billingAccountId: true } } },
});
expect(accountMetadata).toHaveBeenCalledWith(['3', '11', '99']);
});

it('prefers the account used by the matching project over an unassigned newer account', async () => {
findAccounts.mockResolvedValue([
{ id: '99', name: 'Unassigned' },
{ id: '22', name: 'Current' },
]);
findProject.mockResolvedValue({
id: BigInt(123),
billingAccountId: BigInt(22),
});
const { service } = build();
await expect(service.getOpportunity(record.Id)).resolves.toMatchObject({
billingAccount: { id: '22', name: 'Current' },
projectId: '123',
relatedBillingAccounts: [],
});
});

it('does not query challenges outside their project ID integer range', async () => {
findAccounts.mockResolvedValue([{ id: '22', name: 'Current' }]);
findProject.mockResolvedValue({
id: BigInt('2147483648'),
billingAccountId: BigInt(22),
});
const { service } = build();
await expect(service.getOpportunity(record.Id)).resolves.toMatchObject({
projectId: '2147483648',
relatedBillingAccounts: [],
});
expect(findChallenges).not.toHaveBeenCalled();
});

it('reports challenge lookup failure instead of showing an empty lifetime history', async () => {
findAccounts.mockResolvedValue([{ id: '22', name: 'Current' }]);
findProject.mockResolvedValue({
id: BigInt(123),
billingAccountId: BigInt(22),
});
findChallenges.mockRejectedValue(new Error('database connection failed'));
const { service } = build();
await expect(service.getOpportunity(record.Id)).rejects.toBeInstanceOf(
BadGatewayException,
);
});
});
Loading
Loading