From 4e9c2b00c2c8a156393b33a6081cd4986ed0b1a9 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 6 Oct 2026 14:42:37 +1100 Subject: [PATCH] Add project billing history to Salesforce opportunity details --- README.md | 30 +++ .../salesforce-opportunity-response.dto.ts | 40 +++- .../salesforce-opportunity.controller.ts | 12 +- .../salesforce-opportunity.service.spec.ts | 159 +++++++++++++- .../salesforce-opportunity.service.ts | 117 +++++++++- src/api/salesforce/salesforce.module.ts | 4 +- .../services/billingAccount.service.spec.ts | 203 ++++++++++++++++++ src/shared/services/billingAccount.service.ts | 149 +++++++++++-- 8 files changed, 683 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 274c5fa..3364b25 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts b/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts index a189d8c..611dab7 100644 --- a/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts +++ b/src/api/salesforce/dto/salesforce-opportunity-response.dto.ts @@ -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 @@ -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', diff --git a/src/api/salesforce/salesforce-opportunity.controller.ts b/src/api/salesforce/salesforce-opportunity.controller.ts index 0a7661f..668c3e5 100644 --- a/src/api/salesforce/salesforce-opportunity.controller.ts +++ b/src/api/salesforce/salesforce-opportunity.controller.ts @@ -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') @@ -50,10 +50,11 @@ 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) @@ -61,7 +62,7 @@ export class SalesforceOpportunityController { @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', @@ -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.', diff --git a/src/api/salesforce/salesforce-opportunity.service.spec.ts b/src/api/salesforce/salesforce-opportunity.service.spec.ts index 737c1dd..a45b6db 100644 --- a/src/api/salesforce/salesforce-opportunity.service.spec.ts +++ b/src/api/salesforce/salesforce-opportunity.service.spec.ts @@ -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', @@ -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; @@ -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 () => { @@ -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'"), @@ -77,6 +121,9 @@ describe('SalesforceOpportunityService', () => { closeDate: undefined, stageName: undefined, url: 'https://topcoder.my.salesforce.com/006UN00000XamntYAB', + billingAccount: null, + projectId: null, + relatedBillingAccounts: [], }, ); }); @@ -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, + ); + }); }); diff --git a/src/api/salesforce/salesforce-opportunity.service.ts b/src/api/salesforce/salesforce-opportunity.service.ts index 1a13eab..c28a8ce 100644 --- a/src/api/salesforce/salesforce-opportunity.service.ts +++ b/src/api/salesforce/salesforce-opportunity.service.ts @@ -1,8 +1,13 @@ import { BadRequestException, + BadGatewayException, Injectable, NotFoundException, } from '@nestjs/common'; +import { getChallengesPrismaClient } from 'src/shared/global/external-prisma.client'; +import { LoggerService } from 'src/shared/modules/global/logger.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { BillingAccountService } from 'src/shared/services/billingAccount.service'; import { SALESFORCE_OPPORTUNITY_ID_PATTERN, SMU_VALUES, @@ -44,14 +49,115 @@ const OPPORTUNITY_FIELDS = [ * Read-only Salesforce opportunity lookups. * * Backs the Work app's project-detail auto-population and the Sales app's - * opportunity description popup. Nothing is written back to Salesforce. + * opportunity popup, including the matching project and its billing history. + * Nothing is written back to Salesforce or the project/billing databases. */ @Injectable() export class SalesforceOpportunityService { + private readonly logger = LoggerService.forRoot( + 'SalesforceOpportunityService', + ); + /** * @param client Shared read-only Salesforce REST client. + * @param prisma Project database used to resolve the account's current project. + * @param billingAccounts Billing Accounts API lookup and account metadata resolver. */ - constructor(private readonly client: SalesforceClient) {} + constructor( + private readonly client: SalesforceClient, + private readonly prisma: PrismaService, + private readonly billingAccounts: BillingAccountService, + ) {} + + /** + * Resolves an opportunity's current account and the project's lifetime accounts. + * For multiple matches, uses the most recently updated non-deleted project; + * without a project, uses the highest account ID from the filtered listing. + * Challenge status and account active status do not restrict history. + * + * @param opportunityId Salesforce record ID returned by the opportunity query. + * @returns Account, project ID, and unique challenge account summaries. Missing + * historical metadata preserves an ID-only entry. No account yields nulls/[]. + * @throws BadGatewayException when billing, project, or challenge lookup fails; + * failures are not presented as an empty history or a missing association. + */ + private async getBillingContext( + opportunityId: string, + ): Promise< + Pick< + SalesforceOpportunityResponseDto, + 'billingAccount' | 'projectId' | 'relatedBillingAccounts' + > + > { + const accounts = + await this.billingAccounts.getBillingAccountsForOpportunity( + opportunityId, + ); + if (!accounts.length) { + return { + billingAccount: null, + projectId: null, + relatedBillingAccounts: [], + }; + } + try { + const project = await this.prisma.project.findFirst({ + where: { + billingAccountId: { + in: accounts.map((account) => BigInt(account.id)), + }, + deletedAt: null, + }, + select: { id: true, billingAccountId: true }, + orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], + }); + const billingAccount = + accounts.find( + (account) => account.id === project?.billingAccountId?.toString(), + ) || accounts[0]; + if (!project) { + return { billingAccount, projectId: null, relatedBillingAccounts: [] }; + } + // Challenge.projectId is Int while Project.id is BigInt. + const projectId = Number(project.id); + const challenges = + projectId > 0 && projectId <= 2147483647 + ? await getChallengesPrismaClient().challenge.findMany({ + where: { projectId }, + select: { billingRecord: { select: { billingAccountId: true } } }, + }) + : []; + const relatedIds = new Set(); + for (const challenge of challenges) { + const rawId = challenge.billingRecord?.billingAccountId?.trim(); + if (!rawId || !/^\d+$/.test(rawId)) continue; + const id = BigInt(rawId).toString(); + if (id !== '0') relatedIds.add(id); + } + const ids = [...relatedIds].sort((a, b) => + BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0, + ); + const metadata = await this.billingAccounts.getBillingAccountsByIds( + ids.filter((id) => id !== billingAccount.id), + ); + return { + billingAccount, + projectId: project.id.toString(), + relatedBillingAccounts: ids.map((id) => + id === billingAccount.id + ? billingAccount + : { id, name: metadata[id]?.name }, + ), + }; + } catch { + this.logger.warn( + 'Unable to load project billing history for opportunity.', + ); + throw new BadGatewayException( + 'Unable to load opportunity project billing history.', + ); + } + } /** * Reads a string field, trimming it and discarding empty values. @@ -95,10 +201,11 @@ export class SalesforceOpportunityService { } /** - * Retrieves a single opportunity by record id. + * Retrieves a single opportunity and its project billing context by record id. * * @param opportunityId 15 or 18 character Salesforce opportunity id - * @returns the opportunity attributes used by Work and Sales + * @returns the opportunity attributes, current account, project ID and unique + * challenge billing accounts used by Work and Sales * @throws BadRequestException for a malformed id; NotFoundException when no * opportunity is visible to the integration user; ServiceUnavailableException * or BadGatewayException for configuration and upstream failures @@ -128,6 +235,7 @@ export class SalesforceOpportunityService { const resolvedId = this.readString(record.Id) || id; const reportingSmu = this.readString(record.Reporting_SMU__c); + const billingContext = await this.getBillingContext(resolvedId); return { id: resolvedId, @@ -139,6 +247,7 @@ export class SalesforceOpportunityService { closeDate: this.readString(record.CloseDate), stageName: this.readString(record.StageName), url: `${this.client.instanceOrigin()}/${resolvedId}`, + ...billingContext, }; } } diff --git a/src/api/salesforce/salesforce.module.ts b/src/api/salesforce/salesforce.module.ts index a8ea4f6..7968e00 100644 --- a/src/api/salesforce/salesforce.module.ts +++ b/src/api/salesforce/salesforce.module.ts @@ -1,15 +1,17 @@ import { Module } from '@nestjs/common'; +import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; import { SalesforceOpportunityController } from './salesforce-opportunity.controller'; import { SalesforceOpportunityService } from './salesforce-opportunity.service'; import { SalesforceClient } from './salesforce.client'; /** - * Wires the read-only Salesforce opportunity integration. + * Wires Salesforce opportunity reads with project and billing-account lookups. * * The client keeps its OAuth session in memory only; no Salesforce data is * persisted by this module. */ @Module({ + imports: [GlobalProvidersModule], controllers: [SalesforceOpportunityController], providers: [SalesforceClient, SalesforceOpportunityService], exports: [SalesforceOpportunityService], diff --git a/src/shared/services/billingAccount.service.spec.ts b/src/shared/services/billingAccount.service.spec.ts index d4312b3..0ccdd9e 100644 --- a/src/shared/services/billingAccount.service.spec.ts +++ b/src/shared/services/billingAccount.service.spec.ts @@ -1,4 +1,5 @@ import { HttpService } from '@nestjs/axios'; +import { BadGatewayException, BadRequestException } from '@nestjs/common'; import { of, throwError } from 'rxjs'; import { M2MService } from 'src/shared/modules/global/m2m.service'; import { BillingAccountService } from './billingAccount.service'; @@ -27,6 +28,8 @@ describe('BillingAccountService', () => { beforeEach(() => { jest.clearAllMocks(); + httpServiceMock.get.mockReset(); + httpServiceMock.post.mockReset(); process.env = { ...originalEnv, }; @@ -78,6 +81,92 @@ describe('BillingAccountService', () => { expect(httpServiceMock.post).not.toHaveBeenCalled(); }); + it('deduplicates account ids and uses the API without Salesforce configuration', async () => { + delete process.env.SALESFORCE_CLIENT_ID; + httpServiceMock.get.mockImplementation((url: string) => + of({ + data: { + id: Number(url.split('/').pop()), + name: 'Account', + }, + }), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + const result = await service.getBillingAccountsByIds([ + '12', + '12', + '13', + 'bad', + ]); + expect(Object.keys(result)).toEqual(['12', '13']); + expect(result['12']).toMatchObject({ + tcBillingAccountId: '12', + }); + expect(httpServiceMock.get).toHaveBeenCalledTimes(2); + expect(httpServiceMock.post).not.toHaveBeenCalled(); + }); + + it('falls back only for missing accounts and retains API metadata', async () => { + process.env.SALESFORCE_CLIENT_ID = 'client'; + process.env.SALESFORCE_CLIENT_AUDIENCE = 'https://login.salesforce.com'; + process.env.SALESFORCE_SUBJECT = 'user'; + process.env.SALESFORCE_CLIENT_KEY = 'key'; + httpServiceMock.get.mockImplementation((url: string) => + url.endsWith('/12') + ? of({ data: { id: 12, name: 'Engineering' } }) + : throwError(() => new Error('missing')), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + (service as any).authenticate = jest.fn().mockResolvedValue({ + accessToken: 'token', + instanceUrl: 'https://salesforce.test', + }); + (service as any).queryBillingAccountRecords = jest.fn().mockResolvedValue([ + { + TopCoder_Billing_Account_Id__c: '13', + Billing_Account_name__c: 'Legacy', + }, + ]); + const result = await service.getBillingAccountsByIds(['12', '13', '14']); + expect(result['12'].name).toBe('Engineering'); + expect(result['13'].name).toBe('Legacy'); + expect(result['14']).toBeUndefined(); + expect((service as any).queryBillingAccountRecords).toHaveBeenCalledWith( + expect.stringContaining("IN ('13','14')"), + 'token', + 'https://salesforce.test', + ); + }); + + it('omits unresolved accounts when both integrations are unavailable', async () => { + delete process.env.SALESFORCE_CLIENT_ID; + httpServiceMock.get.mockReturnValue( + throwError(() => new Error('unavailable')), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + expect(await service.getBillingAccountsByIds(['12'])).toEqual({}); + }); + + it('skips external requests for empty or invalid ids', async () => { + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + expect(await service.getBillingAccountsByIds([])).toEqual({}); + expect(await service.getBillingAccountsByIds(['', '12bad'])).toEqual({}); + expect(httpServiceMock.get).not.toHaveBeenCalled(); + expect(m2mServiceMock.getM2MToken).not.toHaveBeenCalled(); + }); + it('falls back to Salesforce when the Billing Accounts API lookup fails', async () => { process.env.SALESFORCE_CLIENT_ID = 'salesforce-client-id'; process.env.SALESFORCE_CLIENT_AUDIENCE = 'https://login.salesforce.com'; @@ -122,4 +211,118 @@ describe('BillingAccountService', () => { ); expect(httpServiceMock.get).toHaveBeenCalledTimes(1); }); + + it('loads every opportunity account page and exposes only ID/name summaries', async () => { + const opportunity = '006UN00000XamntYAB'; + httpServiceMock.get + .mockReturnValueOnce( + of({ + data: { + totalPages: 2, + data: [{ id: 22, name: ' Current ', opportunity, markup: '0.5' }], + }, + }), + ) + .mockReturnValueOnce( + of({ + data: { + totalPages: 2, + data: [ + { + id: 11, + name: 'Previous', + opportunity: opportunity.slice(0, 15), + status: 'INACTIVE', + }, + ], + }, + }), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + await expect( + service.getBillingAccountsForOpportunity(opportunity), + ).resolves.toEqual([ + { id: '22', name: 'Current' }, + { id: '11', name: 'Previous' }, + ]); + expect(m2mServiceMock.getM2MToken).toHaveBeenCalledTimes(1); + expect(httpServiceMock.get).toHaveBeenNthCalledWith( + 2, + 'https://billing-accounts.test/v6/billing-accounts', + expect.objectContaining({ + params: { + opportunity, + page: 2, + perPage: 100, + sortBy: 'id', + sortOrder: 'desc', + }, + headers: { Authorization: 'Bearer m2m-token' }, + timeout: 5000, + }), + ); + }); + + it('returns no accounts for an empty filtered listing', async () => { + httpServiceMock.get.mockReturnValueOnce( + of({ data: { totalPages: 0, data: [] } }), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + await expect( + service.getBillingAccountsForOpportunity('006UN00000XamntYAB'), + ).resolves.toEqual([]); + expect(httpServiceMock.get).toHaveBeenCalledTimes(1); + }); + + it('rejects an invalid opportunity before calling upstream', async () => { + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + await expect( + service.getBillingAccountsForOpportunity('bad'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(httpServiceMock.get).not.toHaveBeenCalled(); + }); + + it.each([ + {}, + { data: [], totalPages: '2' }, + { data: [{ id: 22, opportunity: '006000000000001AAA' }], totalPages: 1 }, + { + data: [{ id: 'invalid', opportunity: '006UN00000XamntYAB' }], + totalPages: 1, + }, + ])( + 'rejects invalid or unfiltered responses instead of linking an unrelated project', + async (data) => { + httpServiceMock.get.mockReturnValueOnce(of({ data })); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + await expect( + service.getBillingAccountsForOpportunity('006UN00000XamntYAB'), + ).rejects.toBeInstanceOf(BadGatewayException); + }, + ); + + it('sanitizes failures without treating them as a missing account', async () => { + httpServiceMock.get.mockReturnValueOnce( + throwError(() => new Error('private transport details')), + ); + service = new BillingAccountService( + httpServiceMock as unknown as HttpService, + m2mServiceMock as unknown as M2MService, + ); + await expect( + service.getBillingAccountsForOpportunity('006UN00000XamntYAB'), + ).rejects.toThrow('Unable to load opportunity billing accounts.'); + }); }); diff --git a/src/shared/services/billingAccount.service.ts b/src/shared/services/billingAccount.service.ts index d05ac03..fb1ef67 100644 --- a/src/shared/services/billingAccount.service.ts +++ b/src/shared/services/billingAccount.service.ts @@ -1,11 +1,22 @@ import { HttpService } from '@nestjs/axios'; -import { Injectable } from '@nestjs/common'; +import { + BadGatewayException, + BadRequestException, + Injectable, +} from '@nestjs/common'; import { createPrivateKey, KeyObject } from 'crypto'; import { firstValueFrom } from 'rxjs'; import * as jwt from 'jsonwebtoken'; import { SERVICE_ENDPOINTS } from 'src/shared/config/service-endpoints.config'; import { LoggerService } from 'src/shared/modules/global/logger.service'; import { M2MService } from 'src/shared/modules/global/m2m.service'; +import { SALESFORCE_OPPORTUNITY_ID_PATTERN } from 'src/shared/utils/showcase-metadata.utils'; + +/** Account identity exposed by the Salesforce opportunity integration. */ +export interface BillingAccountSummary { + id: string; + name?: string; +} export interface BillingAccount { tcBillingAccountId?: string; @@ -18,13 +29,15 @@ export interface BillingAccount { } /** - * Salesforce billing-account integration service. + * Billing Accounts API and Salesforce integration service. * - * Uses JWT Bearer OAuth against Salesforce and runs SOQL queries to retrieve - * billing-account data used by Projects API. + * Resolves opportunity accounts and batch metadata through the Billing Accounts + * API. Uses JWT Bearer OAuth/SOQL as a fallback for unresolved account IDs. * * Injected into the billing-account controller for account listing/detail - * endpoints. Requires `SALESFORCE_CLIENT_ID`, `SALESFORCE_CLIENT_AUDIENCE` + * endpoints and the Salesforce opportunity popup. API lookups use + * `BILLING_ACCOUNTS_API_URL` and M2M credentials. The fallback requires + * `SALESFORCE_CLIENT_ID`, `SALESFORCE_CLIENT_AUDIENCE` * (or `SALESFORCE_AUDIENCE`), `SALESFORCE_SUBJECT`, and * `SALESFORCE_CLIENT_KEY`. */ @@ -56,6 +69,80 @@ export class BillingAccountService { private readonly m2mService: M2MService, ) {} + /** + * Finds local billing accounts associated with a Salesforce opportunity. + * Used by the opportunity popup to resolve Project.billingAccountId. Reads + * every filtered page, including inactive accounts, in descending ID order. + * Only account identity is returned; M2M-only financial data stays private. + * + * @param opportunityId Valid 15- or 18-character Salesforce opportunity ID. + * @returns Matching account IDs/names, or an empty array when none exist. + * @throws BadRequestException for an invalid ID; BadGatewayException when + * configuration, authentication, transport, or the upstream response fails. + */ + async getBillingAccountsForOpportunity( + opportunityId: string, + ): Promise { + if (!SALESFORCE_OPPORTUNITY_ID_PATTERN.test(opportunityId)) { + throw new BadRequestException('Invalid Salesforce opportunity ID.'); + } + try { + if (!this.billingAccountsApiUrl) { + throw new Error('Billing Accounts API is not configured.'); + } + const token = await this.m2mService.getM2MToken(); + const accounts = new Map(); + let page = 1; + let totalPages = 1; + do { + const response = await firstValueFrom( + this.httpService.get<{ + data: Record[]; + totalPages: number; + }>(this.billingAccountsApiUrl, { + headers: { Authorization: `Bearer ${token}` }, + params: { + opportunity: opportunityId, + page, + perPage: 100, + sortBy: 'id', + sortOrder: 'desc', + }, + timeout: 5000, + }), + ); + const payload = response.data; + if ( + !Array.isArray(payload?.data) || + !Number.isSafeInteger(payload.totalPages) || + payload.totalPages < 0 + ) { + throw new Error('Invalid billing account listing.'); + } + for (const account of payload.data) { + const id = this.readAsIdString(account?.id); + if ( + !id || + !/^[1-9]\d*$/.test(id) || + typeof account.opportunity !== 'string' || + account.opportunity.slice(0, 15) !== opportunityId.slice(0, 15) + ) { + throw new Error('Invalid opportunity billing account.'); + } + accounts.set(id, { id, name: this.readAsString(account.name) }); + } + totalPages = payload.totalPages; + page += 1; + } while (page <= totalPages); + return [...accounts.values()]; + } catch { + this.logger.warn('Unable to resolve billing accounts for opportunity.'); + throw new BadGatewayException( + 'Unable to load opportunity billing accounts.', + ); + } + } + /** * Returns billing accounts available to the current project user. * @@ -266,30 +353,56 @@ export class BillingAccountService { } /** - * Returns a map of billing-account details keyed by Topcoder account id. + * Loads unique accounts from the Billing Accounts API with at most five + * concurrent requests, then batch-loads unresolved ids from Salesforce. * - * Executes a single SOQL query with an `IN` clause over normalized ids. - * - * @param billingAccountIds billing-account ids to fetch - * @returns record keyed by normalized billing-account id + * @param billingAccountIds Topcoder billing-account ids to resolve. + * @returns Account details keyed by id; unavailable records are omitted. + * Transport/authentication failures are logged and handled by the lookups. */ async getBillingAccountsByIds( billingAccountIds: string[], ): Promise> { - const normalizedBillingAccountIds = Array.from( + const ids = Array.from( new Set( billingAccountIds - .map((billingAccountId) => this.parseIntStrictly(billingAccountId)) - .filter((billingAccountId): billingAccountId is string => - Boolean(billingAccountId), - ), + .map((id) => this.parseIntStrictly(id)) + .filter((id): id is string => Boolean(id)), ), ); - - if (normalizedBillingAccountIds.length === 0) { - return {}; + if (!ids.length) return {}; + + const accounts: Record = {}; + let nextIndex = 0; + await Promise.all( + Array.from({ length: Math.min(5, ids.length) }, async () => { + while (nextIndex < ids.length) { + const id = ids[nextIndex++]; + const account = + await this.getBillingAccountFromBillingAccountsApi(id); + if (account) accounts[id] = account; + } + }), + ); + const missingIds = ids.filter((id) => !accounts[id]); + if (missingIds.length) { + Object.assign( + accounts, + await this.getSalesforceBillingAccountsByIds(missingIds), + ); } + return accounts; + } + /** + * Batch-loads legacy details for accounts unresolved by the Billing Accounts API. + * @param normalizedBillingAccountIds Validated, unique integer ids. + * @returns Details keyed by id, or an empty map when Salesforce is unavailable. + * Authentication and query failures are logged rather than propagated. + */ + private async getSalesforceBillingAccountsByIds( + normalizedBillingAccountIds: string[], + ): Promise> { if (!this.isSalesforceConfigured()) { this.logger.warn('Salesforce integration is not configured.'); return {};