Skip to content

Commit fcce01e

Browse files
committed
PM-4904: Allow project billing account clears
What was broken Clearing a billing account from an existing project did not persist. The Work UI sent an explicit null billingAccountId, but the Projects API treated that value as omitted, so the old billing account stayed on the project and appeared again after save. Root cause UpdateProjectDto inherited CreateProjectDto billingAccountId parsing, which normalizes null and empty string to undefined. The service clear path depended on a derived flag that is not materialized by class-transformer when only billingAccountId is sent. What was changed UpdateProjectDto now owns the update-time billingAccountId field, preserving null and empty string as explicit clear requests while still parsing numeric billing account ids. ProjectService now clears when the DTO billingAccountId is null, in addition to the existing internal clear flag. Any added/updated tests Added DTO coverage for null, empty string, and numeric billingAccountId updates. Updated the project service clear test to exercise billingAccountId: null directly.
1 parent 121ec14 commit fcce01e

4 files changed

Lines changed: 73 additions & 8 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { plainToInstance } from 'class-transformer';
2+
import { validate } from 'class-validator';
3+
import { UpdateProjectDto } from './update-project.dto';
4+
5+
describe('UpdateProjectDto', () => {
6+
it.each([null, ''])(
7+
'preserves %p billingAccountId as an explicit clear request',
8+
async (billingAccountId) => {
9+
const dto = plainToInstance(UpdateProjectDto, {
10+
billingAccountId,
11+
});
12+
13+
expect(dto.billingAccountId).toBeNull();
14+
await expect(validate(dto)).resolves.toHaveLength(0);
15+
},
16+
);
17+
18+
it('parses numeric billingAccountId updates', async () => {
19+
const dto = plainToInstance(UpdateProjectDto, {
20+
billingAccountId: '80001063',
21+
});
22+
23+
expect(dto.billingAccountId).toBe(80001063);
24+
await expect(validate(dto)).resolves.toHaveLength(0);
25+
});
26+
});

src/api/project/dto/update-project.dto.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
1-
import { ApiHideProperty } from '@nestjs/swagger';
1+
import { ApiHideProperty, ApiPropertyOptional } from '@nestjs/swagger';
22
import { Transform } from 'class-transformer';
3-
import { PartialType } from '@nestjs/mapped-types';
3+
import { IsNumber, IsOptional } from 'class-validator';
4+
import { OmitType, PartialType } from '@nestjs/mapped-types';
45
import { CreateProjectDto } from './create-project.dto';
56

7+
/**
8+
* Parses optional project billing-account update input.
9+
*
10+
* @param value Raw `billingAccountId` value from request payload.
11+
* @returns Parsed integer, `null` when clearing, or `undefined` when omitted.
12+
*/
13+
function parseOptionalNullableInteger(value: unknown): number | null | undefined {
14+
if (typeof value === 'undefined') {
15+
return undefined;
16+
}
17+
18+
if (value === null || value === '') {
19+
return null;
20+
}
21+
22+
const parsed = Number(value);
23+
24+
if (Number.isNaN(parsed)) {
25+
return undefined;
26+
}
27+
28+
return Math.trunc(parsed);
29+
}
30+
631
/**
732
* Resolves whether the patch payload explicitly requests clearing
833
* `billingAccountId`.
@@ -17,9 +42,21 @@ function parseClearBillingAccountFlag(value: unknown): boolean {
1742
/**
1843
* Request DTO for `PATCH /projects/:projectId`.
1944
*
20-
* Reuses `CreateProjectDto` and makes all fields optional via `PartialType`.
45+
* Reuses `CreateProjectDto`, makes all fields optional via `PartialType`, and
46+
* allows `billingAccountId` to be explicitly cleared with `null` or `''`.
2147
*/
22-
export class UpdateProjectDto extends PartialType(CreateProjectDto) {
48+
export class UpdateProjectDto extends PartialType(
49+
OmitType(CreateProjectDto, ['billingAccountId'] as const),
50+
) {
51+
@ApiPropertyOptional({
52+
description: 'Project billing account id. Send null or empty string to clear.',
53+
nullable: true,
54+
})
55+
@IsOptional()
56+
@Transform(({ value }) => parseOptionalNullableInteger(value))
57+
@IsNumber()
58+
billingAccountId?: number | null;
59+
2360
@ApiHideProperty()
2461
@Transform(({ obj }) => parseClearBillingAccountFlag(obj?.billingAccountId))
2562
clearBillingAccountId?: boolean;

src/api/project/project.service.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,8 +1646,8 @@ describe('ProjectService', () => {
16461646
await service.updateProject(
16471647
'1001',
16481648
{
1649-
clearBillingAccountId: true,
1650-
} as any,
1649+
billingAccountId: null,
1650+
},
16511651
{
16521652
userId: '100',
16531653
isMachine: false,

src/api/project/project.service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,8 +666,10 @@ export class ProjectService {
666666
throw new ForbiddenException('Insufficient permissions');
667667
}
668668

669+
const shouldClearBillingAccountId =
670+
dto.clearBillingAccountId === true || dto.billingAccountId === null;
669671
const requestedBillingAccountId =
670-
dto.clearBillingAccountId === true
672+
shouldClearBillingAccountId
671673
? null
672674
: typeof dto.billingAccountId === 'number'
673675
? String(dto.billingAccountId)
@@ -726,7 +728,7 @@ export class ProjectService {
726728
cancelReason:
727729
typeof dto.cancelReason === 'string' ? dto.cancelReason : undefined,
728730
billingAccountId:
729-
dto.clearBillingAccountId === true
731+
shouldClearBillingAccountId
730732
? null
731733
: typeof dto.billingAccountId === 'number'
732734
? BigInt(dto.billingAccountId)

0 commit comments

Comments
 (0)