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
26 changes: 26 additions & 0 deletions src/api/project/dto/update-project.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateProjectDto } from './update-project.dto';

describe('UpdateProjectDto', () => {
it.each([null, ''])(
'preserves %p billingAccountId as an explicit clear request',
async (billingAccountId) => {
const dto = plainToInstance(UpdateProjectDto, {
billingAccountId,
});

expect(dto.billingAccountId).toBeNull();
await expect(validate(dto)).resolves.toHaveLength(0);
},
);

it('parses numeric billingAccountId updates', async () => {
const dto = plainToInstance(UpdateProjectDto, {
billingAccountId: '80001063',
});

expect(dto.billingAccountId).toBe(80001063);
await expect(validate(dto)).resolves.toHaveLength(0);
});
});
45 changes: 41 additions & 4 deletions src/api/project/dto/update-project.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { ApiHideProperty } from '@nestjs/swagger';
import { ApiHideProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { PartialType } from '@nestjs/mapped-types';
import { IsNumber, IsOptional } from 'class-validator';
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateProjectDto } from './create-project.dto';

/**
* Parses optional project billing-account update input.
*
* @param value Raw `billingAccountId` value from request payload.
* @returns Parsed integer, `null` when clearing, or `undefined` when omitted.
*/
function parseOptionalNullableInteger(value: unknown): number | null | undefined {
if (typeof value === 'undefined') {
return undefined;
}

if (value === null || value === '') {
return null;
}

const parsed = Number(value);

if (Number.isNaN(parsed)) {
return undefined;
}

return Math.trunc(parsed);
}

/**
* Resolves whether the patch payload explicitly requests clearing
* `billingAccountId`.
Expand All @@ -17,9 +42,21 @@ function parseClearBillingAccountFlag(value: unknown): boolean {
/**
* Request DTO for `PATCH /projects/:projectId`.
*
* Reuses `CreateProjectDto` and makes all fields optional via `PartialType`.
* Reuses `CreateProjectDto`, makes all fields optional via `PartialType`, and
* allows `billingAccountId` to be explicitly cleared with `null` or `''`.
*/
export class UpdateProjectDto extends PartialType(CreateProjectDto) {
export class UpdateProjectDto extends PartialType(
OmitType(CreateProjectDto, ['billingAccountId'] as const),
) {
@ApiPropertyOptional({
description: 'Project billing account id. Send null or empty string to clear.',
nullable: true,
})
@IsOptional()
@Transform(({ value }) => parseOptionalNullableInteger(value))
@IsNumber()
billingAccountId?: number | null;

@ApiHideProperty()
@Transform(({ obj }) => parseClearBillingAccountFlag(obj?.billingAccountId))
clearBillingAccountId?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions src/api/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1646,8 +1646,8 @@ describe('ProjectService', () => {
await service.updateProject(
'1001',
{
clearBillingAccountId: true,
} as any,
billingAccountId: null,
},
{
userId: '100',
isMachine: false,
Expand Down
6 changes: 4 additions & 2 deletions src/api/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,8 +666,10 @@ export class ProjectService {
throw new ForbiddenException('Insufficient permissions');
}

const shouldClearBillingAccountId =
dto.clearBillingAccountId === true || dto.billingAccountId === null;
const requestedBillingAccountId =
dto.clearBillingAccountId === true
shouldClearBillingAccountId
? null
: typeof dto.billingAccountId === 'number'
? String(dto.billingAccountId)
Expand Down Expand Up @@ -726,7 +728,7 @@ export class ProjectService {
cancelReason:
typeof dto.cancelReason === 'string' ? dto.cancelReason : undefined,
billingAccountId:
dto.clearBillingAccountId === true
shouldClearBillingAccountId
? null
: typeof dto.billingAccountId === 'number'
? BigInt(dto.billingAccountId)
Expand Down
Loading